mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 04:03:47 +03:00
884 lines
32 KiB
Go
884 lines
32 KiB
Go
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
|
|
}
|