package oidc import ( "context" "slices" "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/dto" "gorm.io/gorm" ) // SubjectType identifies the subject the requested access token will represent type SubjectType string const ( // SubjectTypeUser covers user-delegated access: every flow whose access token acts on behalf of an end user SubjectTypeUser SubjectType = "user" // SubjectTypeClient covers client access: the client credentials grant, where the client acts as itself without a user SubjectTypeClient SubjectType = "client" ) var standardScopes = fosite.Arguments{"openid", "profile", "email", "groups", "offline_access"} func isStandardScope(scope string) bool { return slices.Contains(standardScopes, scope) } // APIAccessProvider is implemented by the api feature module // It lets the OIDC module widen per-client scope and audience validation and resolve RFC 8707 resources to the permission keys a client may be granted type APIAccessProvider interface { // ClientAPIScopes returns the custom-API permission keys and the distinct API audiences a client is allowed to request across all subject types ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) // AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, and whether such an API exists AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error) // DescribePermissions returns the display information for the given permission keys of the API identified by audience // Unknown keys are omitted DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) } // resolveResource maps an RFC 8707 resource, which may be empty, to the audience to stamp on the issued token and the subset of requestedScopes that may be granted // An empty resource is a plain login token bound to the requesting client and yields only identity scopes // The subject type selects which of the client's grants apply: user-delegated flows only see user grants, the client credentials grant only sees client grants func resolveResource(ctx context.Context, tx *gorm.DB, provider APIAccessProvider, clientID, resource string, requestedScopes []string, subjectType SubjectType) (audience string, grantedScopes []string, err error) { grantable := make(map[string]struct{}, len(standardScopes)) for _, scope := range standardScopes { grantable[scope] = struct{}{} } if resource == "" { // A plain login token is audienced to the requesting client audience = clientID } else { if !fosite.IsValidResourceIndicatorURI(resource) || provider == nil { return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) } allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType) if err != nil { return "", nil, err } if !apiExists { return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) } if len(allowedScopes) == 0 { return "", nil, fosite.ErrAccessDenied.WithHintf("The OAuth 2.0 Client is not allowed to access resource '%s'.", resource) } audience = resource for _, scope := range allowedScopes { grantable[scope] = struct{}{} } // A client credentials request without explicit scopes gets everything the client is granted for the API if subjectType == SubjectTypeClient && len(requestedScopes) == 0 { requestedScopes = allowedScopes } } granted := make(fosite.Arguments, 0, len(requestedScopes)) for _, scope := range requestedScopes { if _, ok := grantable[scope]; !ok { return "", nil, fosite.ErrInvalidScope.WithHintf("The scope '%s' is not available for the requested resource.", scope) } if !granted.Has(scope) { granted = append(granted, scope) } } return audience, granted, nil } // grantResourceIndicator applies a resolved resource audience and scopes to the request func grantResourceIndicator(requester fosite.Requester, audience string, scopes []string) { for _, scope := range scopes { requester.GrantScope(scope) } if audience != "" { requester.GrantAudience(audience) } } // consentScopeKey qualifies a custom-API scope by its audience so the same permission key on two different APIs is consented to separately // The unit-separator delimiter is collision-free because audiences are validated as URIs and permission keys are restricted to RFC 6749 scope-token characters, so neither can contain it // Standard identity scopes stay bare for backward compatibility with existing consents func consentScopeKey(audience, scope string) string { if isStandardScope(scope) { return scope } return audience + "\x1f" + scope } func consentScopeKeys(audience string, scopes []string) []string { keys := make([]string, len(scopes)) for i, scope := range scopes { keys[i] = consentScopeKey(audience, scope) } return keys }