package oidc import ( "context" "errors" "log/slog" "maps" "net/url" "slices" "strconv" "strings" "time" "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" "gorm.io/gorm" ) func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, apiAccess APIAccessProvider) *authorizationService { return &authorizationService{ db: db, interactionSessionService: interactionSessionService, claimsService: claimsService, reauth: reauth, auditLog: auditLog, apiAccess: apiAccess, } } type authorizationService struct { db *gorm.DB interactionSessionService *interactionSessionService claimsService *ClaimsService reauth ReauthenticationTokenConsumer auditLog AuditLogger apiAccess APIAccessProvider } // resolveGrant resolves the RFC 8707 resource of a request into the token audience, the scopes that may actually be granted, and the audience-qualified keys used to record and check consent // It always resolves against the client's user-delegated grants because every flow that passes through here acts on behalf of a user func (s *authorizationService) resolveGrant(ctx context.Context, clientID, resource string, requestedScopes []string) (audience string, grantedScopes []string, consentKeys []string, err error) { audience, grantedScopes, err = resolveResource(ctx, dbFromContext(ctx, s.db), s.apiAccess, clientID, resource, requestedScopes, SubjectTypeUser) if err != nil { return "", nil, nil, err } return audience, grantedScopes, consentScopeKeys(audience, grantedScopes), nil } type requestMeta struct { IPAddress string UserAgent string } type authorizationResult struct { RequiresInteraction bool InteractionID string Session *Session } type promptValues []string func newPromptValues(prompt string) promptValues { return strings.Fields(prompt) } func (p promptValues) has(value string) bool { return slices.Contains(p, value) } // consentRequired reports whether the user has to be shown the consent screen // A client can be configured to skip consent so trusted first-party apps are not prompted on every new authorization, but an explicit prompt=consent always forces the screen regardless of that setting func consentRequired(hasAlreadyAuthorizedClient, clientSkipsConsent bool, prompt promptValues) bool { if prompt.has("consent") { return true } return !hasAlreadyAuthorizedClient && !clientSkipsConsent } // authorizeInput is the authorization request as provided by the handler. type authorizeInput struct { userID string authenticationMethod string authenticationTime time.Time requester fosite.AuthorizeRequester hasPushedAuthorizationRequest bool reauthenticationToken string interactionID string requestParams map[string]string meta requestMeta } // authorizeRequest is the input enriched with everything the service derives from it. type authorizeRequest struct { authorizeInput client Client prompt promptValues interactionSession *InteractionSession now time.Time } func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) { client := input.requester.GetClient().(Client) prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt")) err := validateClientPKCERequirement(client, input.requester) if err != nil { return authorizationResult{}, err } interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester) if err != nil { return authorizationResult{}, err } // Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil { return authorizationResult{}, &common.OidcPARRequiredError{} } resource, err := input.requester.GetResource() if err != nil { return authorizationResult{}, err } // Validate the requested scopes against the targeted API up front, before the user authenticates or reaches the consent screen // This rejects a custom permission requested without, or with the wrong, resource at the authorize endpoint itself _, _, _, err = s.resolveGrant(ctx, client.GetID(), resource, input.requester.GetRequestedScopes()) if err != nil { // resolveGrant distinguishes an unknown API, an API this client is not granted, and a scope not allowed for the API // This validation runs before authentication, so returning those distinct errors would let anyone holding a public client_id diff the responses to enumerate which API audiences exist and which ones the client may request // Collapse every resource-targeted failure into one generic invalid_request so the pre-auth response reveals no backend state, while keeping the underlying reason in the server log for operators // A request that names no resource cannot leak API topology, so its scope error is returned unchanged to help legitimate integrations if resource != "" { slog.DebugContext(ctx, "Rejected authorize request with an invalid or unauthorized resource or scope", "client_id", client.GetID(), "error", err.Error()) return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The 'resource' or 'scope' parameter is invalid.") } return authorizationResult{}, err } if input.userID == "" { if prompt.has("none") { return authorizationResult{}, fosite.ErrLoginRequired } interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{ AuthenticationRequired: true, ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication, AccountSelectionRequired: prompt.has("select_account"), ConsentRequired: prompt.has("consent"), }) if err != nil { return authorizationResult{}, err } return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil } req := authorizeRequest{ authorizeInput: input, client: client, prompt: prompt, interactionSession: interactionSession, now: time.Now().UTC(), } codeChallenge := input.requester.GetRequestForm().Get("code_challenge") var result authorizationResult err = withTx(ctx, s.db, func(ctx context.Context) error { var txErr error result, txErr = s.authorizeAuthenticated(ctx, req) if txErr != nil { return txErr } if codeChallenge != "" && !client.PkceEnabled && !client.PkceSupported { tx := dbFromContext(ctx, s.db) _ = flagPkceSupportedClient(ctx, client.GetID(), tx) } return nil }) if err != nil { return authorizationResult{}, err } if result.Session == nil { return result, nil } err = s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()) if err != nil { return authorizationResult{}, err } return result, nil } // authorizeAuthenticated either reports the interaction the user still has to complete or grants the request. func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) { var user model.User err := dbFromContext(ctx, s.db). Preload("UserGroups"). First(&user, "id = ?", req.userID). Error if err != nil { return authorizationResult{}, err } if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) { return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.") } interactionSession := req.interactionSession if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID { if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil { return authorizationResult{}, err } if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil { return authorizationResult{}, err } } requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession) if err != nil { return authorizationResult{}, err } if requirements.any() { if interactionSession != nil { return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil } created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements) if err != nil { return authorizationResult{}, err } return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil } if interactionSession != nil { err := s.interactionSessionService.delete(ctx, interactionSession.ID) if errors.Is(err, gorm.ErrRecordNotFound) { return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.") } if err != nil { return authorizationResult{}, err } } resource, err := req.requester.GetResource() if err != nil { return authorizationResult{}, err } audience, grantedScopes, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes()) if err != nil { return authorizationResult{}, err } hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), consentKeys) if err != nil { return authorizationResult{}, err } session := s.buildAuthorizedSession(req, interactionSession, authenticationTime) grantResourceIndicator(req.requester, audience, grantedScopes) authorizationEvent := model.AuditLogEventClientAuthorization if !hasAlreadyAuthorizedClient { authorizationEvent = model.AuditLogEventNewClientAuthorization } if s.auditLog != nil { s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db)) } return authorizationResult{Session: session}, nil } func flagPkceSupportedClient(ctx context.Context, clientID string, tx *gorm.DB) error { err := tx. WithContext(ctx). Model(&model.OidcClient{}). Where("id = ?", clientID). Update("pkce_supported", true). Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } return nil } // resolveRequirements determines the interaction steps still required; the requirements // of a resumed interaction session win over the ones derived from the request. func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) { authenticationTime := req.authenticationTime resource, err := req.requester.GetResource() if err != nil { return interactionRequirements{}, authenticationTime, err } _, _, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes()) if err != nil { return interactionRequirements{}, authenticationTime, err } hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, consentKeys) if err != nil { return interactionRequirements{}, authenticationTime, err } maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now) if err != nil { return interactionRequirements{}, authenticationTime, err } requirements := interactionRequirements{ ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, req.client.SkipConsent, req.prompt), ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired, AccountSelectionRequired: req.prompt.has("select_account"), AuthenticationRequired: false, } if interactionSession != nil { requirements = interactionRequirements{ ConsentRequired: interactionSession.ConsentRequired, ReauthenticationRequired: interactionSession.ReauthenticationRequired, AccountSelectionRequired: interactionSession.AccountSelectionRequired, AuthenticationRequired: interactionSession.AuthenticationRequired, } if interactionSession.ReauthenticatedAt != nil { authenticationTime = interactionSession.ReauthenticatedAt.UTC() } } if req.prompt.has("none") && requirements.ConsentRequired { return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired } if req.prompt.has("none") && requirements.ReauthenticationRequired { return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired } if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil { reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID) if err == nil { requirements.ReauthenticationRequired = false authenticationTime = reauthenticatedAt } } return requirements, authenticationTime, nil } func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session { if authenticationTime.IsZero() { authenticationTime = req.now } requestedAt := req.requester.GetRequestedAt() if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() { requestedAt = interactionSession.RequestedAt.UTC() } if requestedAt.IsZero() { requestedAt = req.now } return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt) } // interactionRequestQuery returns the authorize parameters stored for the interaction // session, so the handler can rebuild the re-entry request server-side. func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) { interactionSession, err := s.interactionSessionService.get(ctx, interactionID) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.") } if err != nil { return nil, err } query = url.Values{} for key, value := range interactionSession.Parameters { if value != "" { query.Set(key, value) } } query.Set("interaction", interactionSession.ID) return query, nil } // boundInteractionSession loads the referenced interaction session and validates the request against it. func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) { if interactionID == "" { return nil, nil } session, err := s.interactionSessionService.get(ctx, interactionID) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.") } if err != nil { return nil, err } if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil { return nil, err } return &session, nil } // validateInteractionSessionBinding ensures a request matches the interaction session // it resumes: the requirement flags and the PAR exemption come from the session, so a // tampered request (extra scopes, another client's interaction) must not inherit them. func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error { if interactionSession.ClientID != client.GetID() { return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.") } if interactionSession.UserID != nil { if userID == "" { return fosite.ErrLoginRequired } } for _, scope := range requester.GetRequestedScopes() { if !slices.Contains(interactionSession.Scopes, scope) { return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.") } } return nil } type interactionRequirements struct { ConsentRequired bool ReauthenticationRequired bool AuthenticationRequired bool AccountSelectionRequired bool } func (r interactionRequirements) any() bool { return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired } func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) { parameters := make(map[string]string, len(requestParams)) maps.Copy(parameters, requestParams) scopes := requester.GetRequestedScopes() if scopes == nil { scopes = []string{} } return s.interactionSessionService.create(ctx, InteractionSession{ Base: model.Base{ ID: requester.GetID(), }, Scopes: datatype.StringList(scopes), ClientID: requester.GetClient().GetID(), UserID: utils.PtrOrNil(userID), ConsentRequired: requirements.ConsentRequired, ReauthenticationRequired: requirements.ReauthenticationRequired, AuthenticationRequired: requirements.AuthenticationRequired, AccountSelectionRequired: requirements.AccountSelectionRequired, RequestedAt: datatype.DateTime(requester.GetRequestedAt()), Parameters: parameters, }) } func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error { if userID == "" { return fosite.ErrLoginRequired } if interactionSession.UserID != nil && *interactionSession.UserID != userID { return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.") } if interactionSession.UserID == nil { interactionSession.UserID = &userID } return nil } func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error { if userID == "" { return fosite.ErrLoginRequired } interactionSession.UserID = &userID requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime) if err != nil { return err } interactionSession.AuthenticationRequired = false interactionSession.ConsentRequired = requirements.ConsentRequired interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired interactionSession.ReauthenticatedAt = nil return nil } func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) { interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID) if err != nil { return interactionSessionForUser{}, err } return s.buildInteractionForUser(ctx, interactionSession) } // buildInteractionForUser builds the consent-screen DTO and enriches it with display information for the requested custom-API permissions func (s *authorizationService) buildInteractionForUser(ctx context.Context, interactionSession InteractionSession) (interactionSessionForUser, error) { result, err := newInteractionSessionForUser(interactionSession) if err != nil { return interactionSessionForUser{}, err } scopeInfo, err := s.resolveScopeInfo(ctx, interactionSession) if err != nil { return interactionSessionForUser{}, err } // Always serialize a possibly empty array rather than null if scopeInfo == nil { scopeInfo = []dto.ScopeInfoDto{} } result.ScopeInfo = scopeInfo return result, nil } // resolveScopeInfo resolves display names and descriptions for the requested non-standard scopes, looked up against the API targeted by the request's RFC 8707 resource // Standard identity scopes are rendered by the client func (s *authorizationService) resolveScopeInfo(ctx context.Context, interactionSession InteractionSession) ([]dto.ScopeInfoDto, error) { return s.resolveScopeInfoForRequest(ctx, interactionSession.Parameters["resource"], interactionSession.Scopes) } // resolveScopeInfoForRequest resolves display names and descriptions for the requested non-standard scopes against the API identified by resource // The browser and device consent flows share it so both show friendly permission names instead of raw scope keys func (s *authorizationService) resolveScopeInfoForRequest(ctx context.Context, resource string, scopes []string) ([]dto.ScopeInfoDto, error) { if s.apiAccess == nil { return nil, nil } if resource == "" { return nil, nil } customKeys := make([]string, 0, len(scopes)) for _, scope := range scopes { if !isStandardScope(scope) { customKeys = append(customKeys, scope) } } if len(customKeys) == 0 { return nil, nil } infos, err := s.apiAccess.DescribePermissions(ctx, resource, customKeys) if err != nil { return nil, err } return infos, nil } func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) { var interactionSession InteractionSession var response completeInteractionResponse err := withTx(ctx, s.db, func(ctx context.Context) error { var err error interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID) if err != nil { return err } if interactionSession.UserID != nil && *interactionSession.UserID != userID { if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil { return err } } if userID == "" { return fosite.ErrLoginRequired } requiredSteps := requiredInteractionSteps(interactionSession) if len(requiredSteps) == 0 { response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)} return nil } if requiredSteps[0] != step { return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)} } if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil { return err } return s.interactionSessionService.update(ctx, interactionSession) }) if err != nil { return completeInteractionResponse{}, err } if response.RedirectURL != "" { return response, nil } if !hasRemainingInteractionSteps(interactionSession) { return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil } interaction, err := s.buildInteractionForUser(ctx, interactionSession) if err != nil { return completeInteractionResponse{}, err } return completeInteractionResponse{Interaction: &interaction}, nil } func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error { switch step { case interactionStepAuthenticate: if err := bindInteractionSessionUser(interactionSession, userID); err != nil { return err } interactionSession.AuthenticationRequired = false return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime) case interactionStepSelectAccount: if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil { return err } interactionSession.AccountSelectionRequired = false return nil case interactionStepReauthenticate: return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken) case interactionStepConsent: return s.completeConsentStep(ctx, interactionSession, userID, meta) default: return &common.ValidationError{Message: "unknown interaction step " + string(step)} } } func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error { if err := bindInteractionSessionUser(interactionSession, userID); err != nil { return err } if reauthenticationToken == "" { return &common.ValidationError{Message: "reauthentication token is required"} } reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID) if err != nil { return err } interactionSession.ReauthenticationRequired = false interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt)) return nil } func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error { if err := bindInteractionSessionUser(interactionSession, userID); err != nil { return err } resource := interactionSession.Parameters["resource"] _, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes) if err != nil { return err } hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, consentKeys) if err != nil { return err } if !hasAlreadyAuthorizedClient && s.auditLog != nil { s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db)) } interactionSession.ConsentRequired = false return nil } func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error { if userID == "" { return errors.New("user is required to complete authentication") } requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime) if err != nil { return err } interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired return nil } func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) { prompt := newPromptValues(interactionSession.Parameters["prompt"]) resource := interactionSession.Parameters["resource"] _, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes) if err != nil { return interactionRequirements{}, err } hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, consentKeys) if err != nil { return interactionRequirements{}, err } maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC()) if err != nil { return interactionRequirements{}, err } return interactionRequirements{ ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, interactionSession.Client.SkipConsent, prompt), ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired, AccountSelectionRequired: prompt.has("select_account"), AuthenticationRequired: false, }, nil } // authorizeRedirectURL only references the interaction session; the authorize endpoint // restores the request parameters from it server-side. func authorizeRedirectURL(interactionSessionID string) string { return "/authorize?interaction=" + url.QueryEscape(interactionSessionID) } // validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only // enforces PKCE for public clients (EnforcePKCEForPublicClients). func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error { if !client.PkceEnabled { return nil } if requester.GetRequestForm().Get("code_challenge") == "" { return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.") } return nil } func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) { if maxAgeRaw == "" { return false, nil } maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64) if err != nil || maxAge < 0 { return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.") } if authenticationTime.IsZero() { return true, nil } return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil } func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) { db := dbFromContext(ctx, s.db) now := datatype.DateTime(time.Now()) var existing model.UserAuthorizedOidcClient err = db. First(&existing, "client_id = ? AND user_id = ?", clientID, userID). Error if err == nil { // If the user has already authorized the client with the same or a superset of the requested scopes, we just update the last_used_at timestamp and return hasAlreadyAuthorizedClient = consentScopesInclude(existing.Scope, scope) if hasAlreadyAuthorizedClient { err = db. Model(&model.UserAuthorizedOidcClient{}). Where("user_id = ? AND client_id = ?", userID, clientID). Update("last_used_at", now). Error return hasAlreadyAuthorizedClient, err } // If the user has already authorized the client but with a different set of scopes, we merge the scopes and update the record err = db. Model(&model.UserAuthorizedOidcClient{}). Where("user_id = ? AND client_id = ?", userID, clientID). Updates(map[string]any{ "scope": mergeConsentScopes(existing.Scope, scope), "last_used_at": now, }). Error return hasAlreadyAuthorizedClient, err } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } // The user has not authorized the client yet, so we create a new record err = db.Create(&model.UserAuthorizedOidcClient{ UserID: userID, ClientID: clientID, Scope: mergeConsentScopes(nil, scope), LastUsedAt: now, }).Error if errors.Is(err, gorm.ErrDuplicatedKey) { return s.consent(ctx, userID, clientID, scope) } return false, err } func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) { var userAuthorizedOidcClient model.UserAuthorizedOidcClient err := dbFromContext(ctx, s.db). First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID). Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, err } return consentScopesInclude(userAuthorizedOidcClient.Scope, scope), nil } func consentScopesInclude(authorizedScopes []string, requestedScopes []string) bool { for _, requestedScope := range requestedScopes { if !slices.Contains(authorizedScopes, requestedScope) { return false } } return true } // mergeConsentScopes merges the authorized scopes with the requested scopes, ensuring that there are no duplicates. // Merging is necessary because the user may authorize scopes for multiple resources. // If we would just replace the authorized scopes with the requested scopes, we would lose the previously authorized scopes for other resources. func mergeConsentScopes(authorizedScopes []string, requestedScopes []string) datatype.StringList { merged := make(datatype.StringList, 0, len(authorizedScopes)+len(requestedScopes)) seen := make(map[string]struct{}, len(authorizedScopes)+len(requestedScopes)) for _, scope := range authorizedScopes { if _, ok := seen[scope]; ok { continue } seen[scope] = struct{}{} merged = append(merged, scope) } for _, scope := range requestedScopes { if _, ok := seen[scope]; ok { continue } seen[scope] = struct{}{} merged = append(merged, scope) } return merged } // IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client. func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool { if !client.IsGroupRestricted { return true } for _, userGroup := range client.AllowedUserGroups { for _, userGroupUser := range user.UserGroups { if userGroup.ID == userGroupUser.ID { return true } } } return false }