mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved - Token exchange rejects `urn:zitadel:iam:org:roles🆔{orgID}` when that scope is not already present on the subject or actor token (`invalid_scope`). - A documented least-privilege pattern — exchanging a broad access token for one whose roles claims are limited to a single organization — has been broken since the v4.15.3 token-exchange hardening and was not restored by #12312. - A naive allow-any-`roles:id` exception would let a previously narrowed token widen again (switch org filter, or drop the filter and get all role orgs), including via an actor token's filter on actor-path exchanges. - A client-requested bare `urn:zitadel:iam:org:roles🆔` (empty org id) is not a meaningful filter and should be rejected on exchange. # How the Problems Are Solved - Treats `OrgRoleIDScope` as a restriction filter with downscope-only semantics via `applyOrgRoleIDScopeDownscoping(requested, ceilingScopes)`: - union / standard exchange passes the **subject** token scopes as the ceiling (actor filters ignored) - impersonation (scopeless `user_id` / `id_token` subject) passes the **actor** token scopes as the ceiling - ceiling unfiltered → any requested `roles:id` is allowed (broad → narrow) - ceiling filtered → requested filters must be ⊆ that set - ceiling filtered but request omits them → inherit the ceiling (omit must not re-widen) - Rejects a **client-requested** bare `urn:zitadel:iam:org:roles🆔` in `validateTokenExchangeScopes` after normalize (before empty-request fallback). Input tokens may still carry the bare prefix as a degenerate ceiling; empty-request / omit inherit does not re-trigger the rejection. - Keeps non-restriction scopes on the existing subject ∪ actor presence checks. - Adds unit coverage for downscope, further downscope, widen rejection, omit-inherit, mixed subject/actor filters, empty-scope access/JWT subjects, and bare-prefix request/ceiling cases. - Adds an integration test for broad → `roles:id` filtered roles, plus a second exchange that rejects widening to another org. - Documents downscope-only and omit-inherit behavior in the reserved scopes table and the token exchange guide. # Additional Changes - Updates the token-exchange guide Scope section to reflect that authorization-related reserved scopes must already be present on input tokens (aligned with the v4.15.3 hardening), rather than suggesting scope can be freely extended. # Additional Context - Closes #12413 - Follow-up for PR #12312 - Related discussion in #11869 (recommended `roles:id` workaround; this restores that path for token exchange without allowing filter widening) - Behavior note for release notes: during token exchange, an existing `urn:zitadel:iam:org:roles🆔*` filter on the subject is inherited when omitted from the request, so role claims cannot re-widen. --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Livio Spring <[email protected]>
554 lines
20 KiB
Go
554 lines
20 KiB
Go
package oidc
|
||
|
||
import (
|
||
"context"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||
"github.com/zitadel/oidc/v3/pkg/op"
|
||
"golang.org/x/text/language"
|
||
|
||
"github.com/zitadel/zitadel/internal/api/authz"
|
||
"github.com/zitadel/zitadel/internal/api/oidc/sign"
|
||
"github.com/zitadel/zitadel/internal/domain"
|
||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||
"github.com/zitadel/zitadel/internal/zerrors"
|
||
)
|
||
|
||
const (
|
||
UserIDTokenType oidc.TokenType = "urn:zitadel:params:oauth:token-type:user_id"
|
||
|
||
// TokenTypeNA is set when the returned Token Exchange access token value can't be used as an access token.
|
||
// For example, when it is an ID Token.
|
||
// See [RFC 8693, section 2.2.1, token_type](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.1)
|
||
TokenTypeNA = "N_A"
|
||
)
|
||
|
||
func init() {
|
||
oidc.AllTokenTypes = append(oidc.AllTokenTypes, UserIDTokenType)
|
||
}
|
||
|
||
func (s *Server) TokenExchange(ctx context.Context, r *op.ClientRequest[oidc.TokenExchangeRequest]) (_ *op.Response, err error) {
|
||
resp, err := s.tokenExchange(ctx, r)
|
||
if err != nil {
|
||
return nil, oidcError(ctx, err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Server) tokenExchange(ctx context.Context, r *op.ClientRequest[oidc.TokenExchangeRequest]) (_ *op.Response, err error) {
|
||
ctx, span := tracing.NewSpan(ctx)
|
||
defer func() { span.EndWithError(err) }()
|
||
|
||
if len(r.Data.Resource) > 0 {
|
||
return nil, oidc.ErrInvalidTarget().WithDescription("resource parameter not supported")
|
||
}
|
||
|
||
client, ok := r.Client.(*Client)
|
||
if !ok {
|
||
// not supposed to happen, but just preventing a panic if it does.
|
||
return nil, zerrors.ThrowInternal(nil, "OIDC-eShi5", "Error.Internal")
|
||
}
|
||
|
||
subjectToken, err := s.verifyExchangeToken(ctx, client, r.Data.SubjectToken, r.Data.SubjectTokenType, oidc.AllTokenTypes...)
|
||
if err != nil {
|
||
return nil, oidc.ErrInvalidRequest().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("subject_token invalid")
|
||
}
|
||
|
||
actorToken := subjectToken // see [createExchangeTokens] comment.
|
||
// actorPath: a separate actor_token was verified (impersonation/delegation).
|
||
// Standard exchange leaves actorToken == subjectToken; this branch runs for
|
||
// user_id/JWT subjects or when the client sends actor_token explicitly.
|
||
actorPath := false
|
||
if subjectToken.tokenType == UserIDTokenType || subjectToken.tokenType == oidc.JWTTokenType || r.Data.ActorToken != "" {
|
||
actorPath = true
|
||
if !authz.GetInstance(ctx).EnableImpersonation() {
|
||
return nil, zerrors.ThrowPermissionDenied(nil, "OIDC-Fae5w", "Errors.TokenExchange.Impersonation.PolicyDisabled")
|
||
}
|
||
actorToken, err = s.verifyExchangeToken(ctx, client, r.Data.ActorToken, r.Data.ActorTokenType, oidc.AccessTokenType, oidc.IDTokenType, oidc.RefreshTokenType)
|
||
if err != nil {
|
||
return nil, oidc.ErrInvalidRequest().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("actor_token invalid")
|
||
}
|
||
ctx = authz.SetCtxData(ctx, authz.CtxData{
|
||
UserID: actorToken.userID,
|
||
OrgID: actorToken.resourceOwner,
|
||
})
|
||
}
|
||
|
||
audience, err := validateTokenExchangeAudience(r.Data.Audience, subjectToken.audience, actorToken.audience)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// scopelessActorPath: actorPath with a subject that cannot carry scopes
|
||
// (user_id or id_token). Subject-data scopes (email, profile, …) are taken
|
||
// from the impersonated user at mint time; only authorization scopes must
|
||
// appear on subject_token ∪ actor_token. Access/JWT subjects always use
|
||
// union validation even when their scope claim is empty.
|
||
scopelessActorPath := actorPath && (subjectToken.tokenType == UserIDTokenType || subjectToken.tokenType == oidc.IDTokenType)
|
||
scopes, err := validateTokenExchangeScopes(client, r.Data.Scopes, subjectToken.scopes, actorToken.scopes, scopelessActorPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
resp, err := s.createExchangeTokens(ctx, r.Data.RequestedTokenType, client, subjectToken, actorToken, audience, scopes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return op.NewResponse(resp), nil
|
||
}
|
||
|
||
// verifyExchangeToken verifies the passed token based on the token type. It is safe to pass both from the request as-is.
|
||
// A list of allowed token types must be passed to determine which types are trusted at a particular stage of the token exchange.
|
||
func (s *Server) verifyExchangeToken(ctx context.Context, client *Client, token string, tokenType oidc.TokenType, allowed ...oidc.TokenType) (*exchangeToken, error) {
|
||
if token == "" {
|
||
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-lei0O", "Errors.TokenExchange.Token.Missing")
|
||
}
|
||
if tokenType == "" {
|
||
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-sei9V", "Errors.TokenExchange.Token.TypeMissing")
|
||
}
|
||
if !slices.Contains(allowed, tokenType) {
|
||
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-OZ1ie", "Errors.TokenExchange.Token.TypeNotAllowed")
|
||
}
|
||
|
||
switch tokenType {
|
||
case oidc.AccessTokenType:
|
||
token, err := s.verifyAccessToken(ctx, token)
|
||
if err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Osh3t", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
if !token.isPAT {
|
||
if err = validateIntrospectionAudience(token.audience, client.GetID(), client.client.ProjectID); err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-zi9Y0", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
}
|
||
if token.isPAT {
|
||
if err = s.assertClientScopesForPAT(ctx, token, client.GetID(), client.client.ProjectID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return accessToExchangeToken(token, op.IssuerFromContext(ctx)), nil
|
||
|
||
case oidc.IDTokenType:
|
||
verifier := op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), s.idTokenHintKeySet)
|
||
claims, err := op.VerifyIDTokenHint[*oidc.IDTokenClaims](ctx, token, verifier)
|
||
if err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Rei0f", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
resourceOwner, ok := claims.Claims[ClaimResourceOwnerID].(string)
|
||
if !ok || resourceOwner == "" {
|
||
user, err := s.query.GetUserByID(ctx, false, token)
|
||
if err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-aD0Oo", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
resourceOwner = user.ResourceOwner
|
||
}
|
||
|
||
return idTokenClaimsToExchangeToken(claims, resourceOwner), nil
|
||
|
||
case oidc.JWTTokenType:
|
||
var (
|
||
resourceOwner string
|
||
preferredLanguage *language.Tag
|
||
)
|
||
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, client.client.ClockSkew, s.jwtProfileUserCheck(ctx, &resourceOwner, &preferredLanguage))
|
||
jwt, err := op.VerifyJWTAssertion(ctx, token, verifier)
|
||
if err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-eiS6o", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
return jwtToExchangeToken(jwt, resourceOwner, preferredLanguage), nil
|
||
|
||
case UserIDTokenType:
|
||
user, err := s.query.GetUserByID(ctx, false, token)
|
||
if err != nil {
|
||
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Nee6r", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
return userToExchangeToken(user), nil
|
||
|
||
case oidc.RefreshTokenType:
|
||
fallthrough
|
||
default:
|
||
return nil, zerrors.ThrowInvalidArgument(nil, "OIDC-oda4R", "Errors.TokenExchange.Token.TypeNotSupported")
|
||
}
|
||
}
|
||
|
||
// jwtProfileUserCheck finds the user by subject (user ID) and sets the resourceOwner through the pointer.
|
||
// preferred Language is set only if it was defined for a Human user, else the pointed pointer remains nil.
|
||
func (s *Server) jwtProfileUserCheck(ctx context.Context, resourceOwner *string, preferredLanguage **language.Tag) op.JWTProfileVerifierOption {
|
||
return op.SubjectCheck(func(request *oidc.JWTTokenRequest) error {
|
||
user, err := s.query.GetUserByID(ctx, false, request.Subject)
|
||
if err != nil {
|
||
return zerrors.ThrowPermissionDenied(err, "OIDC-Nee6r", "Errors.TokenExchange.Token.Invalid")
|
||
}
|
||
*resourceOwner = user.ResourceOwner
|
||
if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
|
||
*preferredLanguage = &user.Human.PreferredLanguage
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// validateTokenExchangeScopes picks the scope rule set for this exchange.
|
||
// scopelessActorPath selects impersonation rules (user_id/id_token subjects
|
||
// on the actor path); otherwise every requested scope must ⊆ subject ∪ actor,
|
||
// with OrgRoleIDScope handled as a downscoping filter (see
|
||
// applyOrgRoleIDScopeDownscoping).
|
||
func validateTokenExchangeScopes(
|
||
client *Client,
|
||
requestedScopes, subjectScopes, actorScopes []string,
|
||
scopelessActorPath bool,
|
||
) ([]string, error) {
|
||
requestedScopes = normalizeRequestedScopes(requestedScopes)
|
||
// Reject a client-requested bare prefix only. Input tokens may still carry
|
||
// it (authorize accepts it); empty-request fallback inherits that ceiling.
|
||
for _, scope := range requestedScopes {
|
||
if scope == domain.OrgRoleIDScope {
|
||
return nil, oidc.ErrInvalidScope().
|
||
WithDescription("scope %q requires an organization id", scope)
|
||
}
|
||
}
|
||
|
||
if scopelessActorPath {
|
||
return validateImpersonationTokenExchangeScopes(client, requestedScopes, subjectScopes, actorScopes)
|
||
}
|
||
|
||
return validateUnionTokenExchangeScopes(client, requestedScopes, subjectScopes, actorScopes)
|
||
}
|
||
|
||
// validateUnionTokenExchangeScopes applies when the requested scopes must be a subset
|
||
// of the scopes present on the subject and/or actor tokens (union).
|
||
// This covers standard exchange (actor == subject) and actor-path exchanges with
|
||
// access/JWT subject tokens. Actor-path exchanges with scope-less subjects
|
||
// (user_id, id_token) use validateImpersonationTokenExchangeScopes instead.
|
||
//
|
||
// Non-restriction scopes must be present on subject ∪ actor. OrgRoleIDScope is
|
||
// validated separately against the subject token's filter ceiling so a broad
|
||
// subject can be narrowed, but a previously narrowed subject filter cannot be
|
||
// widened (actor filters are ignored on this path).
|
||
func validateUnionTokenExchangeScopes(
|
||
client *Client,
|
||
requestedScopes, subjectScopes, actorScopes []string,
|
||
) ([]string, error) {
|
||
if len(requestedScopes) == 0 {
|
||
requestedScopes = subjectScopes
|
||
}
|
||
if len(requestedScopes) == 0 {
|
||
requestedScopes = actorScopes
|
||
}
|
||
|
||
for _, scope := range requestedScopes {
|
||
if isTokenExchangeRestrictionScope(scope) {
|
||
continue
|
||
}
|
||
if !scopeInUnion(scope, subjectScopes, actorScopes) {
|
||
return nil, oidc.ErrInvalidScope().
|
||
WithDescription("scope %q not found in subject or actor token", scope)
|
||
}
|
||
}
|
||
|
||
requestedScopes, err := applyOrgRoleIDScopeDownscoping(requestedScopes, subjectScopes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return op.ValidateAuthReqScopes(client, requestedScopes)
|
||
}
|
||
|
||
// validateImpersonationTokenExchangeScopes applies when the subject token
|
||
// cannot carry scopes (user_id, id_token). The actor token authorizes the
|
||
// request; impersonation permission is checked later in CreateOIDCSession.
|
||
//
|
||
// Scope classes:
|
||
// - authorization scopes: privilege/audience/roles — must be on input tokens
|
||
// - restriction scopes: OrgRoleIDScope filter — downscope-only vs actor filters
|
||
// - subject-data scopes: user claims (email, profile, …) — client allowlist only
|
||
func validateImpersonationTokenExchangeScopes(
|
||
client *Client,
|
||
requestedScopes, subjectScopes, actorScopes []string,
|
||
) ([]string, error) {
|
||
// Subject has no scopes to inherit; fall back to actor.
|
||
if len(requestedScopes) == 0 {
|
||
requestedScopes = actorScopes
|
||
}
|
||
|
||
for _, scope := range requestedScopes {
|
||
// Prevent privilege escalation: offline_access, audiences, roles, etc.
|
||
// must still come from subject or actor token.
|
||
if isTokenExchangeAuthorizationScope(scope) && !scopeInUnion(scope, subjectScopes, actorScopes) {
|
||
return nil, oidc.ErrInvalidScope().
|
||
WithDescription("scope %q not found in subject or actor token", scope)
|
||
}
|
||
|
||
// Restriction and subject-data scopes skip the union check —
|
||
// restriction scopes are validated below; subject-data by client allowlist.
|
||
}
|
||
|
||
// Subject cannot carry scopes; the actor filter is the only available ceiling.
|
||
requestedScopes, err := applyOrgRoleIDScopeDownscoping(requestedScopes, actorScopes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return op.ValidateAuthReqScopes(client, requestedScopes)
|
||
}
|
||
|
||
func normalizeRequestedScopes(scopes []string) []string {
|
||
// Space-delimited empty scope produces a single "" entry.
|
||
return slices.DeleteFunc(scopes, func(s string) bool {
|
||
return s == ""
|
||
})
|
||
}
|
||
|
||
func scopeInUnion(scope string, subjectScopes, actorScopes []string) bool {
|
||
return slices.Contains(subjectScopes, scope) || slices.Contains(actorScopes, scope)
|
||
}
|
||
|
||
// isTokenExchangeRestrictionScope identifies scopes that only filter token
|
||
// claims. They may be newly requested when the input tokens are unfiltered,
|
||
// but must not expand an existing filter — see applyOrgRoleIDScopeDownscoping.
|
||
func isTokenExchangeRestrictionScope(scope string) bool {
|
||
return strings.HasPrefix(scope, domain.OrgRoleIDScope)
|
||
}
|
||
|
||
// orgRoleIDScopes returns the OrgRoleIDScope entries from scopes, preserving order
|
||
// and dropping duplicates.
|
||
func orgRoleIDScopes(scopes []string) []string {
|
||
var out []string
|
||
for _, scope := range scopes {
|
||
if !isTokenExchangeRestrictionScope(scope) {
|
||
continue
|
||
}
|
||
if !slices.Contains(out, scope) {
|
||
out = append(out, scope)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applyOrgRoleIDScopeDownscoping enforces that OrgRoleIDScope can only narrow
|
||
// role-org filters relative to ceilingScopes (the caller's chosen ceiling):
|
||
// - union / standard exchange passes the subject token scopes
|
||
// - impersonation (scopeless subject) passes the actor token scopes
|
||
//
|
||
// Rules:
|
||
// - ceiling unfiltered → any requested filter is allowed (broad → narrow)
|
||
// - ceiling filtered → requested filters must be ⊆ that set
|
||
// - ceiling filtered but request omits the filter → inherit the ceiling
|
||
// (omitting would otherwise re-widen role claims to all granted orgs)
|
||
func applyOrgRoleIDScopeDownscoping(requested, ceilingScopes []string) ([]string, error) {
|
||
allowed := orgRoleIDScopes(ceilingScopes)
|
||
requestedFilters := orgRoleIDScopes(requested)
|
||
|
||
if len(allowed) == 0 {
|
||
return requested, nil
|
||
}
|
||
if len(requestedFilters) == 0 {
|
||
return append(slices.Clone(requested), allowed...), nil
|
||
}
|
||
for _, scope := range requestedFilters {
|
||
if !slices.Contains(allowed, scope) {
|
||
return nil, oidc.ErrInvalidScope().
|
||
WithDescription("scope %q expands org role filter beyond the permitted downscope set", scope)
|
||
}
|
||
}
|
||
return requested, nil
|
||
}
|
||
|
||
// isTokenExchangeAuthorizationScope identifies scopes that control what a token
|
||
// may do (audiences, refresh, roles), not which user claims to embed.
|
||
// Restriction scopes are intentionally excluded — see isTokenExchangeRestrictionScope.
|
||
func isTokenExchangeAuthorizationScope(scope string) bool {
|
||
switch scope {
|
||
case oidc.ScopeOfflineAccess, ScopeProjectsRoles:
|
||
return true
|
||
}
|
||
if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
|
||
return true // urn:zitadel:iam:org:project:role:*
|
||
}
|
||
if strings.HasPrefix(scope, domain.ProjectIDScope) {
|
||
return true // urn:zitadel:iam:org:project:id:*:aud
|
||
}
|
||
if strings.HasPrefix(scope, domain.OrgIDScope) {
|
||
return true
|
||
}
|
||
if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) {
|
||
return true
|
||
}
|
||
if strings.HasPrefix(scope, domain.SelectIDPScope) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validateTokenExchangeAudience(requestedAudience, subjectAudience, actorAudience []string) ([]string, error) {
|
||
if len(requestedAudience) == 0 {
|
||
if len(subjectAudience) > 0 {
|
||
return subjectAudience, nil
|
||
}
|
||
if len(actorAudience) > 0 {
|
||
return actorAudience, nil
|
||
}
|
||
}
|
||
if slices.Equal(requestedAudience, subjectAudience) || slices.Equal(requestedAudience, actorAudience) {
|
||
return requestedAudience, nil
|
||
}
|
||
|
||
//nolint:gocritic
|
||
allowedAudience := append(subjectAudience, actorAudience...)
|
||
for _, a := range requestedAudience {
|
||
if !slices.Contains(allowedAudience, a) {
|
||
return nil, oidc.ErrInvalidTarget().WithDescription("audience %q not found in subject or actor token", a)
|
||
}
|
||
}
|
||
return requestedAudience, nil
|
||
}
|
||
|
||
// createExchangeTokens prepares the final tokens to be returned to the client.
|
||
// The subjectToken is used to set the new token's subject and resource owner.
|
||
// The actorToken is used to set the new token's auth time AMR and actor.
|
||
// Both tokens may point to the same object (subjectToken) in case of a regular Token Exchange.
|
||
// When the subject and actor Tokens point to different objects, the new tokens will be for impersonation / delegation.
|
||
func (s *Server) createExchangeTokens(ctx context.Context, tokenType oidc.TokenType, client *Client, subjectToken, actorToken *exchangeToken, audience, scopes []string) (_ *oidc.TokenExchangeResponse, err error) {
|
||
getUserInfo := s.getUserInfo(subjectToken.userID, client.client.ProjectID, client.GetID(), client.client.ProjectRoleAssertion, client.IDTokenUserinfoClaimsAssertion(), scopes)
|
||
getSigner := s.getSignerOnce()
|
||
|
||
resp := &oidc.TokenExchangeResponse{
|
||
Scopes: scopes,
|
||
}
|
||
|
||
reason := domain.TokenReasonExchange
|
||
actor := actorToken.actor
|
||
if subjectToken != actorToken {
|
||
reason = domain.TokenReasonImpersonation
|
||
actor = actorToken.nestedActor()
|
||
}
|
||
|
||
var sessionID string
|
||
switch tokenType {
|
||
case oidc.AccessTokenType, "":
|
||
resp.AccessToken, resp.RefreshToken, sessionID, resp.ExpiresIn, err = s.createExchangeAccessToken(ctx, client, subjectToken.userID, subjectToken.resourceOwner, audience, scopes, actorToken.authMethods, actorToken.authTime, subjectToken.preferredLanguage, reason, actor)
|
||
resp.TokenType = oidc.BearerToken
|
||
resp.IssuedTokenType = oidc.AccessTokenType
|
||
|
||
case oidc.JWTTokenType:
|
||
resp.AccessToken, resp.RefreshToken, resp.ExpiresIn, err = s.createExchangeJWT(ctx, client, getUserInfo, client.client.AccessTokenRoleAssertion, getSigner, subjectToken.userID, subjectToken.resourceOwner, audience, scopes, actorToken.authMethods, actorToken.authTime, subjectToken.preferredLanguage, reason, actor)
|
||
resp.TokenType = oidc.BearerToken
|
||
resp.IssuedTokenType = oidc.JWTTokenType
|
||
|
||
case oidc.IDTokenType:
|
||
resp.AccessToken, resp.ExpiresIn, err = s.createIDToken(ctx, client, getUserInfo, client.client.IDTokenRoleAssertion, getSigner, "", resp.AccessToken, audience, actorToken.authMethods, actorToken.authTime, "", actor)
|
||
resp.TokenType = TokenTypeNA
|
||
resp.IssuedTokenType = oidc.IDTokenType
|
||
|
||
case oidc.RefreshTokenType, UserIDTokenType:
|
||
fallthrough
|
||
default:
|
||
err = zerrors.ThrowInvalidArgument(nil, "OIDC-wai5E", "Errors.TokenExchange.Token.TypeNotSupported")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if slices.Contains(scopes, oidc.ScopeOpenID) && tokenType != oidc.IDTokenType {
|
||
resp.IDToken, _, err = s.createIDToken(ctx, client, getUserInfo, client.client.IDTokenRoleAssertion, getSigner, sessionID, resp.AccessToken, audience, actorToken.authMethods, actorToken.authTime, "", actor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Server) createExchangeAccessToken(
|
||
ctx context.Context,
|
||
client *Client,
|
||
userID,
|
||
resourceOwner string,
|
||
audience,
|
||
scope []string,
|
||
authMethods []domain.UserAuthMethodType,
|
||
authTime time.Time,
|
||
preferredLanguage *language.Tag,
|
||
reason domain.TokenReason,
|
||
actor *domain.TokenActor,
|
||
) (accessToken, refreshToken, sessionID string, exp uint64, err error) {
|
||
ctx, span := tracing.NewSpan(ctx)
|
||
defer func() { span.EndWithError(err) }()
|
||
|
||
session, err := s.command.CreateOIDCSession(ctx,
|
||
userID,
|
||
resourceOwner,
|
||
client.client.ClientID,
|
||
client.client.BackChannelLogoutURI,
|
||
scope,
|
||
audience,
|
||
authMethods,
|
||
authTime,
|
||
"",
|
||
preferredLanguage,
|
||
nil,
|
||
reason,
|
||
actor,
|
||
slices.Contains(scope, oidc.ScopeOfflineAccess),
|
||
"",
|
||
domain.OIDCResponseTypeUnspecified,
|
||
)
|
||
if err != nil {
|
||
return "", "", "", 0, err
|
||
}
|
||
accessToken, err = op.CreateBearerToken(session.TokenID, userID, s.opCrypto)
|
||
if err != nil {
|
||
return "", "", "", 0, err
|
||
}
|
||
return accessToken, session.RefreshToken, session.SessionID, timeToOIDCExpiresIn(session.Expiration), nil
|
||
}
|
||
|
||
func (s *Server) createExchangeJWT(
|
||
ctx context.Context,
|
||
client *Client,
|
||
getUserInfo userInfoFunc,
|
||
roleAssertion bool,
|
||
getSigner sign.SignerFunc,
|
||
userID,
|
||
resourceOwner string,
|
||
audience,
|
||
scope []string,
|
||
authMethods []domain.UserAuthMethodType,
|
||
authTime time.Time,
|
||
preferredLanguage *language.Tag,
|
||
reason domain.TokenReason,
|
||
actor *domain.TokenActor,
|
||
) (accessToken string, refreshToken string, exp uint64, err error) {
|
||
ctx, span := tracing.NewSpan(ctx)
|
||
defer func() { span.EndWithError(err) }()
|
||
|
||
session, err := s.command.CreateOIDCSession(ctx,
|
||
userID,
|
||
resourceOwner,
|
||
client.client.ClientID,
|
||
client.client.BackChannelLogoutURI,
|
||
scope,
|
||
audience,
|
||
authMethods,
|
||
authTime,
|
||
"",
|
||
preferredLanguage,
|
||
nil,
|
||
reason,
|
||
actor,
|
||
slices.Contains(scope, oidc.ScopeOfflineAccess),
|
||
"",
|
||
domain.OIDCResponseTypeUnspecified,
|
||
)
|
||
if err != nil {
|
||
return "", "", 0, err
|
||
}
|
||
accessToken, err = s.createJWT(ctx, client, session, getUserInfo, roleAssertion, getSigner)
|
||
if err != nil {
|
||
return "", "", 0, err
|
||
}
|
||
return accessToken, session.RefreshToken, timeToOIDCExpiresIn(session.Expiration), nil
|
||
}
|