diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 8d072cc3673..3a4209ddb2d 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -315,6 +315,8 @@ OIDC: DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 Features: + # Wheter projection triggers are used in the new Introspection implementation. + TriggerIntrospectionProjections: false # Allows fallback to the Legacy Introspection implementation LegacyIntrospection: false diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go new file mode 100644 index 00000000000..2a4818aecaf --- /dev/null +++ b/internal/api/oidc/access_token.go @@ -0,0 +1,103 @@ +package oidc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" + "github.com/zitadel/zitadel/internal/command" + errz "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/user/model" +) + +type accessToken struct { + tokenID string + userID string + subject string + clientID string + audience []string + scope []string + tokenCreation time.Time + tokenExpiration time.Time + isPAT bool +} + +func (s *Server) verifyAccessToken(ctx context.Context, tkn string) (*accessToken, error) { + var tokenID, subject string + + if tokenIDSubject, err := s.Provider().Crypto().Decrypt(tkn); err == nil { + split := strings.Split(tokenIDSubject, ":") + if len(split) != 2 { + return nil, errors.New("invalid token format") + } + tokenID, subject = split[0], split[1] + } else { + verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.keySet) + claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, tkn, verifier) + if err != nil { + return nil, err + } + tokenID, subject = claims.JWTID, claims.Subject + } + + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + token, err := s.query.ActiveAccessTokenByToken(ctx, tokenID) + if err != nil { + return nil, err + } + return accessTokenV2(tokenID, subject, token), nil + } + + token, err := s.repo.TokenByIDs(ctx, subject, tokenID) + if err != nil { + return nil, errz.ThrowPermissionDenied(err, "OIDC-Dsfb2", "token is not valid or has expired") + } + return accessTokenV1(tokenID, subject, token), nil +} + +func accessTokenV1(tokenID, subject string, token *model.TokenView) *accessToken { + return &accessToken{ + tokenID: tokenID, + userID: token.UserID, + subject: subject, + clientID: token.ApplicationID, + audience: token.Audience, + scope: token.Scopes, + tokenCreation: token.CreationDate, + tokenExpiration: token.Expiration, + isPAT: token.IsPAT, + } +} + +func accessTokenV2(tokenID, subject string, token *query.OIDCSessionAccessTokenReadModel) *accessToken { + return &accessToken{ + tokenID: tokenID, + userID: token.UserID, + subject: subject, + clientID: token.ClientID, + audience: token.Audience, + scope: token.Scope, + tokenCreation: token.AccessTokenCreation, + tokenExpiration: token.AccessTokenExpiration, + } +} + +func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToken, clientID, projectID string) error { + token.audience = append(token.audience, clientID) + projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) + if err != nil { + return errz.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") + } + roles, err := s.query.SearchProjectRoles(ctx, s.features.TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, false) + if err != nil { + return err + } + for _, role := range roles.ProjectRoles { + token.scope = append(token.scope, ScopeProjectRolePrefix+role.Key) + } + return nil +} diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index ce63392f87c..589198a4481 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -5,18 +5,15 @@ import ( "database/sql" "errors" "slices" - "strings" "time" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" errz "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/user/model" ) func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionRequest]) (resp *op.Response, err error) { @@ -84,6 +81,14 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR if err != nil { return nil, err } + + // TODO: can we get rid of this seperate query? + if token.isPAT { + if err = s.assertClientScopesForPAT(ctx, token.accessToken, client.clientID, client.projectID); err != nil { + return nil, err + } + } + if err = validateIntrospectionAudience(token.audience, client.clientID, client.projectID); err != nil { return nil, err } @@ -165,89 +170,18 @@ func (s *Server) clientFromCredentials(ctx context.Context, cc *op.ClientCredent } type introspectionTokenResult struct { - tokenID string - userID string - subject string - clientID string - audience []string - scope []string - tokenCreation time.Time - tokenExpiration time.Time - isPAT bool - + *accessToken err error } -func (s *Server) introspectionToken(ctx context.Context, accessToken string, rc chan<- *introspectionTokenResult) { +func (s *Server) introspectionToken(ctx context.Context, tkn string, rc chan<- *introspectionTokenResult) { ctx, span := tracing.NewSpan(ctx) - - result, err := func() (_ *introspectionTokenResult, err error) { - var tokenID, subject string - - if tokenIDSubject, err := s.Provider().Crypto().Decrypt(accessToken); err == nil { - split := strings.Split(tokenIDSubject, ":") - if len(split) != 2 { - return nil, errors.New("invalid token format") - } - tokenID, subject = split[0], split[1] - } else { - verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.keySet) - claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, verifier) - if err != nil { - return nil, err - } - tokenID, subject = claims.JWTID, claims.Subject - } - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := s.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - rc <- &introspectionTokenResult{err: err} - return nil, err - } - return introspectionTokenResultV2(tokenID, subject, token), nil - } - - token, err := s.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return nil, errz.ThrowPermissionDenied(err, "OIDC-Dsfb2", "token is not valid or has expired") - } - return introspectionTokenResultV1(tokenID, subject, token), nil - }() - + token, err := s.verifyAccessToken(ctx, tkn) span.EndWithError(err) - if err != nil { - rc <- &introspectionTokenResult{err: err} - return - } - rc <- result -} - -func introspectionTokenResultV1(tokenID, subject string, token *model.TokenView) *introspectionTokenResult { - return &introspectionTokenResult{ - tokenID: tokenID, - userID: token.UserID, - subject: subject, - clientID: token.ApplicationID, - audience: token.Audience, - scope: token.Scopes, - tokenCreation: token.CreationDate, - tokenExpiration: token.Expiration, - isPAT: token.IsPAT, - } -} - -func introspectionTokenResultV2(tokenID, subject string, token *query.OIDCSessionAccessTokenReadModel) *introspectionTokenResult { - return &introspectionTokenResult{ - tokenID: tokenID, - userID: token.UserID, - subject: subject, - clientID: token.ClientID, - audience: token.Audience, - scope: token.Scope, - tokenCreation: token.AccessTokenCreation, - tokenExpiration: token.AccessTokenExpiration, + rc <- &introspectionTokenResult{ + accessToken: token, + err: err, } } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index 76caff42898..e615efabcfb 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -65,7 +65,8 @@ type Endpoint struct { } type Features struct { - LegacyIntrospection bool + TriggerIntrospectionProjections bool + LegacyIntrospection bool } type OPStorage struct {