Files
pocket-id/backend/internal/oidc/authorization_service.go
T

953 lines
35 KiB
Go

package oidc
import (
"context"
"errors"
"log/slog"
"maps"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/pocket-id/fosite"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"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"
)
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, consentKeysForGrant(audience, resource, 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{}, apperror.OidcPARRequired()
}
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 errors.Is(err, gorm.ErrRecordNotFound) {
return authorizationResult{}, apperror.UserNotFound()
}
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 := auditlogs.EventClientAuthorization
if !hasAlreadyAuthorizedClient {
authorizationEvent = auditlogs.EventNewClientAuthorization
}
if s.auditLog != nil {
s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, auditlogs.Data{"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 errors.Is(err, gorm.ErrRecordNotFound) {
return interactionSessionForUser{}, apperror.OidcInteractionNotFound()
}
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, approvedScopes []string, 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 errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.OidcInteractionNotFound()
}
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 apperror.ValidationMessage("expected interaction step " + string(requiredSteps[0]) + " but got " + string(step))
}
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, approvedScopes, 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, approvedScopes []string, 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, approvedScopes, meta)
default:
return apperror.ValidationMessage("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 apperror.MissingField("reauthenticationToken")
}
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, approvedScopes []string, meta requestMeta) error {
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
// Narrow the request to the scopes the user approved before recording consent, so deselected scopes are neither consented to nor granted
if err := narrowInteractionScopes(interactionSession, approvedScopes); err != nil {
return err
}
// Record consent for the approved scopes only
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, auditlogs.EventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, auditlogs.Data{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
}
interactionSession.ConsentRequired = false
return nil
}
// requiredConsentScopes are the scopes a user cannot deselect on the consent screen because the client cannot sign the user in without them
var requiredConsentScopes = []string{"openid"}
// narrowApprovedScopes restricts the requested scopes to the ones the user approved on a consent screen
// The browser and device consent flows share it so both apply the same rules to a selection
// A nil approval keeps every requested scope, so callers that send no selection keep the all-or-nothing behavior
func narrowApprovedScopes(requestedScopes, approvedScopes []string) ([]string, error) {
if approvedScopes == nil {
return requestedScopes, nil
}
// The approval may only remove scopes, never add ones the client did not request
for _, scope := range approvedScopes {
if !slices.Contains(requestedScopes, scope) {
return nil, apperror.ValidationMessage("scope " + scope + " was not requested")
}
}
// Keep the requested order so the narrowed request only differs from the original by the removed scopes
narrowed := make([]string, 0, len(requestedScopes))
for _, scope := range requestedScopes {
if slices.Contains(approvedScopes, scope) || slices.Contains(requiredConsentScopes, scope) {
narrowed = append(narrowed, scope)
}
}
return narrowed, nil
}
// narrowInteractionScopes restricts the interaction session to the scopes the user approved on the consent screen
// The stored scope parameter is rewritten too because the resumed authorize request is rebuilt from it, which keeps deselected scopes out of the granted scopes and the issued tokens
func narrowInteractionScopes(interactionSession *InteractionSession, approvedScopes []string) error {
if approvedScopes == nil {
return nil
}
narrowed, err := narrowApprovedScopes(interactionSession.Scopes, approvedScopes)
if err != nil {
return err
}
interactionSession.Scopes = narrowed
if interactionSession.Parameters == nil {
interactionSession.Parameters = InteractionSessionParameters{}
}
interactionSession.Parameters["scope"] = strings.Join(narrowed, " ")
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
}