Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions backend/internal/oauth/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,22 @@ func Initialize(
resolver := jwksresolver.Initialize(httpClient)
scopeValidator := scope.Initialize()
discoveryService := discovery.Initialize(mux, runtimeCrypto, jweService, cfg)
jtiStore := jti.Initialize(runtimeStore)
// The revocation services are constructed by the service manager, not here: the session service
// needs the same criteria revoker, and it is wired before the OAuth engine. This registers the
// RFC 7009 routes against the already-built service.
if cfg.OAuth.TokenRevocation.IsEnabled() {
revocation.RegisterRoutes(mux, jwtService, actorProvider, authnProvider, discoveryService, revocationSvc)
revocation.RegisterRoutes(mux, jwtService, actorProvider, authnProvider, discoveryService,
revocationSvc, jtiStore, cfg.JWT.Leeway)
} else {
enforcementService = nil
revocationSvc = nil
}

jtiStore := jti.Initialize(runtimeStore)
tokenBuilder, tokenValidator := tokenservice.Initialize(
cfg, jwtService, jweService, resolver, idpService, enforcementService, jtiStore)
parService := par.Initialize(mux, actorProvider, authnProvider, jwtService, discoveryService,
resourceService, dpopVerifier, cfg, runtimeStore)
resourceService, dpopVerifier, cfg, runtimeStore, jtiStore)
oauth2AuthzService, err := oauth2authz.Initialize(mux, actorProvider, resourceService,
jwtService, flowExecService, parService, revocationSvc, cfg, runtimeStore, transactioner)
if err != nil {
Expand All @@ -89,7 +90,7 @@ func Initialize(
if len(cfg.OAuth.AllowedGrantTypes) == 0 ||
slices.Contains(cfg.OAuth.AllowedGrantTypes, string(providers.GrantTypeCIBA)) {
cibaService = ciba.Initialize(mux, jwtService, actorProvider, authnProvider, flowExecService,
discoveryService, resourceService, runtimeStore, cfg)
discoveryService, resourceService, runtimeStore, jtiStore, cfg)
}

grantHandlerProvider := granthandlers.Initialize(
Expand All @@ -98,8 +99,9 @@ func Initialize(
cibaService, revocationSvc, revocationSvc, cfg)

token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider,
scopeValidator, observabilitySvc, discoveryService, dpopVerifier, cfg)
introspect.Initialize(mux, jwtService, actorProvider, authnProvider, discoveryService, tokenValidator)
scopeValidator, observabilitySvc, discoveryService, dpopVerifier, jtiStore, cfg)
introspect.Initialize(mux, jwtService, actorProvider, authnProvider, discoveryService, tokenValidator,
jtiStore, cfg.JWT.Leeway)
userinfo.Initialize(mux, jwtService, jweService, resolver,
tokenValidator, actorProvider, attributeCacheSvc,
discoveryService, dpopVerifier, cfg)
Expand Down
10 changes: 8 additions & 2 deletions backend/internal/oauth/oauth2/ciba/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth"
"github.com/thunder-id/thunderid/internal/oauth/oauth2/constants"
"github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery"
"github.com/thunder-id/thunderid/internal/oauth/oauth2/jti"
"github.com/thunder-id/thunderid/internal/system/jose/jwt"
"github.com/thunder-id/thunderid/internal/system/middleware"
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
Expand All @@ -29,12 +30,14 @@ func Initialize(
discoveryService discovery.DiscoveryServiceInterface,
resourceService providers.ResourceServerProvider,
runtimeStore providers.RuntimeStoreProvider,
jtiStore jti.JTIStoreInterface,
cfg oauthconfig.Config,
) CIBAServiceInterface {
store := newCIBAStore(runtimeStore)
cibaSvc := newCIBAService(store, flowExecService, jwtService, actorProvider, resourceService, cfg)
cibaHandler := newCIBAHandler(cibaSvc)
registerRoutes(mux, cibaHandler, actorProvider, authnProvider, jwtService, discoveryService)
registerRoutes(mux, cibaHandler, actorProvider, authnProvider, jwtService, discoveryService,
jtiStore, cfg.JWT.Leeway)
return cibaSvc
}

Expand All @@ -47,6 +50,8 @@ func registerRoutes(
authnProvider providers.AuthnProviderManager,
jwtService jwt.JWTServiceInterface,
discoveryService discovery.DiscoveryServiceInterface,
jtiStore jti.JTIStoreInterface,
leeway int64,
) {
corsOpts := middleware.CORSOptions{
AllowedMethods: []string{"POST"},
Expand All @@ -56,7 +61,8 @@ func registerRoutes(
}

issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer
clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer)
clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService,
jtiStore, issuer, leeway)
authHandler := clientAuthMiddleware(http.HandlerFunc(cibaHandler.HandleBackchannelAuthRequest))

authPattern, wrappedAuthHandler := middleware.WithCORS(
Expand Down
54 changes: 51 additions & 3 deletions backend/internal/oauth/oauth2/clientauth/clientauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ import (
"net/http"
"net/url"
"strings"
"time"

"github.com/thunder-id/thunderid/internal/cert"
"github.com/thunder-id/thunderid/internal/oauth/oauth2/constants"
"github.com/thunder-id/thunderid/internal/oauth/oauth2/jti"
serverconst "github.com/thunder-id/thunderid/internal/system/constants"
"github.com/thunder-id/thunderid/internal/system/jose/jwt"
"github.com/thunder-id/thunderid/internal/system/log"
"github.com/thunder-id/thunderid/internal/system/utils"
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
)

// jtiNamespace identifies private_key_jwt client assertions in the shared JTI replay store.
const jtiNamespace = "client_assertion"
Comment thread
thiva-k marked this conversation as resolved.

// authenticate authenticates the OAuth2 client from the request.
// It extracts credentials, validates them, and returns OAuthClientInfo on success.
// The issuer is the audience value accepted when validating client assertion JWTs.
Expand All @@ -32,7 +37,9 @@ func authenticate(
actorProvider providers.ActorProvider,
authnProvider providers.AuthnProviderManager,
jwtService jwt.JWTServiceInterface,
jtiStore jti.JTIStoreInterface,
issuer string,
leeway int64,
) (*OAuthClientInfo, *authError) {
logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "ClientAuthMiddleware"))

Expand Down Expand Up @@ -134,8 +141,8 @@ func authenticate(
switch detectedMethod {
// TODO: Move this to authnProvider.Authenticate
case providers.TokenEndpointAuthMethodPrivateKeyJWT:
if err := validateClientAssertion(ctx, oauthApp, jwtService, issuer, clientID,
clientAssertion); err != nil {
if err := validateClientAssertion(ctx, oauthApp, jwtService, jtiStore, issuer, clientID,
clientAssertion, leeway); err != nil {
logger.Debug(ctx, "Invalid client assertion: "+err.Error())
return nil, errInvalidClientAssertion
}
Expand Down Expand Up @@ -223,8 +230,10 @@ func extractClientIDFromAssertion(ctx context.Context, assertion string) (string
func validateClientAssertion(ctx context.Context,
oauthApp *providers.OAuthClient,
jwtService jwt.JWTServiceInterface,
jtiStore jti.JTIStoreInterface,
issuer string,
clientID, clientAssertion string) error {
clientID, clientAssertion string,
leeway int64) error {
if oauthApp.Certificate == nil {
return fmt.Errorf("no certificate configured for client assertion validation")
}
Expand All @@ -243,6 +252,20 @@ func validateClientAssertion(ctx context.Context,
return fmt.Errorf("client assertion 'aud' claim %q does not match the issuer", aud)
}

if err := verifyAssertionSignature(ctx, oauthApp, jwtService, issuer, clientID, clientAssertion); err != nil {
return err
}

// Replay protection: record the assertion's jti so it cannot be reused within its validity window.
return recordAssertionJTI(ctx, jtiStore, payload, leeway)
}

// verifyAssertionSignature verifies the client assertion's signature against the client's configured
// certificate, resolving the verification key from either a JWKS URI or an inline JWKS.
func verifyAssertionSignature(ctx context.Context,
oauthApp *providers.OAuthClient,
jwtService jwt.JWTServiceInterface,
issuer, clientID, clientAssertion string) error {
if oauthApp.Certificate.Type == cert.CertificateTypeJWKSURI {
if err := jwtService.VerifyJWTWithJWKS(ctx, clientAssertion, oauthApp.Certificate.Value, issuer,
clientID); err != nil {
Expand Down Expand Up @@ -285,3 +308,28 @@ func validateClientAssertion(ctx context.Context,

return nil
}

// recordAssertionJTI enforces one-time use of a verified client assertion by recording its jti in
// the shared replay store.
func recordAssertionJTI(ctx context.Context, jtiStore jti.JTIStoreInterface,
payload map[string]interface{}, leeway int64) error {
jtiValue, ok := payload[constants.ClaimJTI].(string)
if !ok || jtiValue == "" {
return fmt.Errorf("client assertion missing 'jti' claim or 'jti' is not a string")
}
exp, ok := payload[constants.ClaimExp].(float64)
if !ok {
return fmt.Errorf("client assertion missing 'exp' claim or 'exp' is not a number")
}

expiry := time.Unix(int64(exp)+leeway, 0)
inserted, err := jtiStore.RecordJTI(ctx, jtiNamespace, jtiValue, expiry)
if err != nil {
return fmt.Errorf("failed to record client assertion jti: %w", err)
}
if !inserted {
return fmt.Errorf("client assertion replay detected")
}

return nil
}
Comment thread
thiva-k marked this conversation as resolved.
Loading
Loading