diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index b98980358d9..26688e529be 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -14,97 +14,202 @@ import ( ) func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionRequest]) (_ *op.Response, err error) { - clientID, err := s.authenticateResourceClient(ctx, r.Data.ClientCredentials) - if err != nil { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + clientChan := make(chan *instrospectionClientResult) + go s.instrospectionClientAuth(ctx, r.Data.ClientCredentials, clientChan) + + tokenChan := make(chan *introspectionTokenResult) + go s.introspectionToken(ctx, r.Data.Token, tokenChan) + + var ( + client *instrospectionClientResult + token *introspectionTokenResult + ) + + // make sure both channels are always read, + // and cancel the context on first error + for i := 0; i < 2; i++ { + var resErr error + + select { + case client = <-clientChan: + resErr = client.err + case token = <-tokenChan: + resErr = token.err + } + + if resErr == nil { + continue + } + cancel() + + // we only care for the first error that occured, + // as the next error is most probably a context error. + if err == nil { + err = resErr + } + } + + // only client auth errors should be returned + var target *oidc.Error + if errors.As(err, &target) && target.ErrorType == oidc.UnauthorizedClient { return nil, err } + + // all other errors should result in a response with active: false. response := new(oidc.IntrospectionResponse) - tokenID, subject, err := s.getTokenIDAndSubject(ctx, r.Data.Token) if err != nil { // TODO: log error return op.NewResponse(response), nil } - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - err = s.introspect(ctx, response, tokenID, subject, clientID) + if err = validateIntrospectionAudience(token.audience, client.clientID, client.projectID); err != nil { + // TODO: log error return op.NewResponse(response), nil } - - err = s.storage.SetIntrospectionFromToken(ctx, response, tokenID, subject, clientID) + userInfo, err := s.storage.query.GetOIDCUserinfo(ctx, token.userID, token.scope, []string{client.projectID}) if err != nil { + // TODO: log error return op.NewResponse(response), nil } + response.SetUserInfo(userinfoToOIDC(userInfo, token.scope)) + response.Scope = token.scope + response.ClientID = token.clientID + response.TokenType = oidc.BearerToken + response.Expiration = oidc.FromTime(token.tokenExpiration) + response.IssuedAt = oidc.FromTime(token.tokenCreation) + response.NotBefore = oidc.FromTime(token.tokenCreation) + response.Audience = token.audience + response.Issuer = op.IssuerFromContext(ctx) + response.JWTID = token.tokenID response.Active = true return op.NewResponse(response), nil } -func (s *Server) authenticateResourceClient(ctx context.Context, cc *op.ClientCredentials) (clientID string, err error) { +type instrospectionClientResult struct { + clientID string + projectID string + err error +} + +func (s *Server) instrospectionClientAuth(ctx context.Context, cc *op.ClientCredentials, rc chan<- *instrospectionClientResult) { + clientID := cc.ClientID + if cc.ClientAssertion != "" { verifier := op.NewJWTProfileVerifier(s.storage, op.IssuerFromContext(ctx), 1*time.Hour, time.Second) profile, err := op.VerifyJWTAssertion(ctx, cc.ClientAssertion, verifier) if err != nil { - return "", err + rc <- &instrospectionClientResult{ + err: oidc.ErrUnauthorizedClient().WithParent(err), + } + return } - return profile.Issuer, nil - } - - if err = s.storage.AuthorizeClientIDSecret(ctx, cc.ClientID, cc.ClientSecret); err != nil { - if err != nil { - return "", err + clientID = profile.Issuer + } else { + if err := s.storage.AuthorizeClientIDSecret(ctx, cc.ClientID, cc.ClientSecret); err != nil { + if err != nil { + rc <- &instrospectionClientResult{ + err: oidc.ErrUnauthorizedClient().WithParent(err), + } + return + } } - } - return cc.ClientID, nil -} -func (s *Server) getTokenIDAndSubject(ctx context.Context, accessToken string) (idToken, subject string, err error) { - provider := s.Provider() - tokenIDSubject, err := provider.Crypto().Decrypt(accessToken) - if err == nil { - splitToken := strings.Split(tokenIDSubject, ":") - if len(splitToken) != 2 { - return "", "", errors.New("invalid token format") - } - return splitToken[0], splitToken[1], nil } - verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.storage.keySet) - accessTokenClaims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, verifier) - if err != nil { - return "", "", err - } - return accessTokenClaims.JWTID, accessTokenClaims.Subject, nil -} - -func (s *Server) introspect(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { // TODO: give clients their own aggregate, so we can skip this query projectID, err := s.storage.query.ProjectIDFromClientID(ctx, clientID, false) if err != nil { - return errz.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") + rc <- &instrospectionClientResult{err: err} + return } - token, err := s.storage.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err + rc <- &instrospectionClientResult{ + clientID: clientID, + projectID: projectID, } - if !slices.ContainsFunc(token.Audience, func(aud string) bool { - return aud == token.ClientID || aud == projectID - }) { - return errz.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") - } - - userInfo, err := s.storage.query.GetOIDCUserinfo(ctx, token.UserID, token.Scope, []string{projectID}) - if err != nil { - return err - } - introspection.SetUserInfo(userinfoToOIDC(userInfo, token.Scope)) - introspection.Scope = token.Scope - introspection.ClientID = token.ClientID - introspection.TokenType = oidc.BearerToken - introspection.Expiration = oidc.FromTime(token.AccessTokenExpiration) - introspection.IssuedAt = oidc.FromTime(token.AccessTokenCreation) - introspection.NotBefore = oidc.FromTime(token.AccessTokenCreation) - introspection.Audience = token.Audience - introspection.Issuer = op.IssuerFromContext(ctx) - introspection.JWTID = tokenID - - return nil +} + +type introspectionTokenResult struct { + tokenID string + userID string + subject string + clientID string + audience []string + scope []string + tokenCreation time.Time + tokenExpiration time.Time + isPAT bool + + err error +} + +func (s *Server) introspectionToken(ctx context.Context, accessToken string, rc chan<- *introspectionTokenResult) { + var tokenID, subject string + + if tokenIDSubject, err := s.Provider().Crypto().Decrypt(accessToken); err == nil { + split := strings.Split(tokenIDSubject, ":") + if len(split) != 2 { + rc <- &introspectionTokenResult{err: errors.New("invalid token format")} + return + } + tokenID, subject = split[0], split[1] + } else { + verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.storage.keySet) + claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, verifier) + if err != nil { + rc <- &introspectionTokenResult{err: err} + return + } + tokenID, subject = claims.JWTID, claims.Subject + } + + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + token, err := s.storage.query.ActiveAccessTokenByToken(ctx, tokenID) + if err != nil { + rc <- &introspectionTokenResult{err: err} + return + } + rc <- &introspectionTokenResult{ + tokenID: tokenID, + userID: token.UserID, + subject: subject, + clientID: token.ClientID, + audience: token.Audience, + scope: token.Scope, + tokenCreation: token.AccessTokenCreation, + tokenExpiration: token.AccessTokenExpiration, + } + return + } + + token, err := s.storage.repo.TokenByIDs(ctx, subject, tokenID) + if err != nil { + rc <- &introspectionTokenResult{ + err: errz.ThrowPermissionDenied(err, "OIDC-Dsfb2", "token is not valid or has expired"), + } + return + } + rc <- &introspectionTokenResult{ + tokenID: tokenID, + userID: token.UserID, + subject: subject, + clientID: token.ApplicationID, // check correctness? + audience: token.Audience, + scope: token.Scopes, + tokenCreation: token.CreationDate, + tokenExpiration: token.Expiration, + isPAT: token.IsPAT, + } +} + +func validateIntrospectionAudience(audience []string, clientID, projectID string) error { + if slices.ContainsFunc(audience, func(entry string) bool { + return entry == clientID || entry == projectID + }) { + return nil + } + + return errz.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") }