Edward Sammut Alessi ad6cf5b1e3
feat: enforce auth_time in auth0 token validation
In the backend enfore auth_time checks to validate that a re-authentication has taken place, and that the token meets our minimum age policy.

Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
2026-02-24 16:25:15 +01:00

135 lines
3.5 KiB
Go

// Copyright (c) 2026 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
package auth0
import (
"context"
"errors"
"fmt"
"net/mail"
"net/url"
"time"
"github.com/auth0/go-jwt-middleware/v2/jwks"
"github.com/auth0/go-jwt-middleware/v2/validator"
"github.com/siderolabs/go-api-signature/pkg/jwt"
)
const (
tokenValidationCacheDuration = 5 * time.Minute
tokenValidationAllowedClockSkew = 5 * time.Minute
tokenValidationMaxAge = 2 * time.Minute
)
// IDTokenVerifier is an Auth0 ID token verifier.
type IDTokenVerifier struct {
validator *validator.Validator
}
// NewIDTokenVerifier creates a new ID token verifier.
func NewIDTokenVerifier(domain, clientID string) (*IDTokenVerifier, error) {
issuerURL, err := url.Parse("https://" + domain + "/")
if err != nil {
return nil, err
}
provider := jwks.NewCachingProvider(issuerURL, tokenValidationCacheDuration)
idTokenValidator, err := validator.New(
provider.KeyFunc,
validator.RS256,
issuerURL.String(),
[]string{clientID},
validator.WithAllowedClockSkew(tokenValidationAllowedClockSkew),
validator.WithCustomClaims(func() validator.CustomClaims {
return &CustomIDClaims{}
}),
)
if err != nil {
return nil, err
}
return &IDTokenVerifier{
validator: idTokenValidator,
}, nil
}
// Verify verifies the given Auth0 ID token with the configured Auth0 domain (JWKs).
func (v *IDTokenVerifier) Verify(ctx context.Context, token string) (*jwt.Claims, error) {
claims, err := v.validator.ValidateToken(ctx, token)
if err != nil {
return nil, err
}
validatedClaims, ok := claims.(*validator.ValidatedClaims)
if !ok {
return nil, errors.New("unexpected claims type")
}
customClaims, ok := validatedClaims.CustomClaims.(*CustomIDClaims)
if !ok {
return nil, errors.New("unexpected custom claims type")
}
if customClaims.AuthTime == 0 {
return nil, errors.New("auth_time claim is missing")
}
authTime := time.Unix(customClaims.AuthTime, 0)
if authTime.After(time.Now().Add(tokenValidationAllowedClockSkew)) {
return nil, errors.New("auth_time is in the future")
}
if time.Since(authTime) > tokenValidationMaxAge+tokenValidationAllowedClockSkew {
return nil, errors.New("re-authentication required")
}
return &jwt.Claims{
VerifiedEmail: customClaims.Email,
}, nil
}
// IDClaims represents the claims of an ID token.
type IDClaims struct {
validator.RegisteredClaims
CustomIDClaims
}
// CustomIDClaims is the custom claims we expect to be present in ID tokens.
type CustomIDClaims struct {
// Email is the email address of the user.
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
// AuthTime is the time when the user last authenticated, as a Unix timestamp.
// It is included when max_age or prompt=login is used in the auth request.
AuthTime int64 `json:"auth_time"`
}
// Validate validates the claims on the CustomIDClaims.
func (a *CustomIDClaims) Validate(_ context.Context) error {
_, err := mail.ParseAddress(a.Email)
if err != nil {
return fmt.Errorf("email claim is not valid: %w: %s", err, a.Email)
}
if !a.EmailVerified {
return &EmailNotVerifiedError{Email: a.Email}
}
return nil
}
// EmailNotVerifiedError is an error that occurs when the email address is not verified.
type EmailNotVerifiedError struct {
Email string
}
// Error implements the error interface.
func (e EmailNotVerifiedError) Error() string {
return "email not verified: " + e.Email
}