From e9fb5735c2e2410afed035b3b9046eb53371e1b1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 1 Feb 2024 16:29:03 +0100 Subject: [PATCH] feat: add experimental support for RFC9396 - Rich Authorization Requests --- docs/README.md | 162 +++++++++++++++++- example/routes/koa.js | 5 + example/views/_layout.ejs | 6 + example/views/interaction.ejs | 12 +- lib/actions/authorization/index.js | 11 +- lib/actions/authorization/interactions.js | 1 + .../authorization/process_request_object.js | 2 + .../authorization/process_response_types.js | 7 + .../pushed_authorization_request_response.js | 4 + lib/actions/authorization/unsupported_rar.js | 9 + lib/actions/discovery.js | 6 +- lib/actions/grants/authorization_code.js | 11 +- lib/actions/grants/ciba.js | 8 +- lib/actions/grants/client_credentials.js | 10 +- lib/actions/grants/device_code.js | 8 +- lib/actions/grants/refresh_token.js | 25 ++- lib/actions/introspection.js | 8 +- lib/consts/client_attributes.js | 3 + lib/helpers/client_schema.js | 6 + lib/helpers/combined_scope.js | 2 + lib/helpers/configuration.js | 24 ++- lib/helpers/defaults.js | 130 +++++++++++++- lib/helpers/errors.js | 1 + lib/helpers/features.js | 6 + lib/helpers/initialize_app.js | 3 + .../interaction_policy/prompts/consent.js | 11 ++ lib/helpers/oidc_context.js | 8 + lib/models/access_token.js | 1 + lib/models/authorization_code.js | 1 + lib/models/formats/jwt.js | 5 +- lib/models/grant.js | 6 + lib/models/refresh_token.js | 1 + lib/shared/check_rar.js | 75 ++++++++ lib/shared/check_resource.js | 13 +- lib/views/interaction.js | 12 +- lib/views/layout.js | 6 + test/configuration/client_metadata.test.js | 17 ++ 37 files changed, 600 insertions(+), 26 deletions(-) create mode 100644 lib/actions/authorization/unsupported_rar.js create mode 100644 lib/shared/check_rar.js diff --git a/docs/README.md b/docs/README.md index 2d8cf3746..430c26dcb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -582,7 +582,7 @@ _**recommendation**_: The following action order is recommended when rotating si Enable/disable features. Some features are still either based on draft or experimental RFCs. Enabling those will produce a warning in your console and you must be aware that breaking changes may occur between draft implementations and that those will be published as minor versions of oidc-provider. See the example below on how to acknowledge the specification is a draft (this will remove the warning log) and ensure the provider instance will fail to instantiate if a new version of oidc-provider bundles newer version of the RFC with breaking changes in it. -
(Click to expand) Acknowledging a draft / experimental feature +
(Click to expand) Acknowledging an experimental feature
```js @@ -596,7 +596,7 @@ new Provider('http://localhost:3000', { // The above code produces this NOTICE // NOTICE: The following draft features are enabled and their implemented version not acknowledged // NOTICE: - OpenID Connect Back-Channel Logout 1.0 - draft 06 (OIDF AB/Connect Working Group draft. URL: https://openid.net/specs/openid-connect-backchannel-1_0-06.html) -// NOTICE: Breaking changes between draft version updates may occur and these will be published as MINOR semver oidc-provider updates. +// NOTICE: Breaking changes between experimental feature updates may occur and these will be published as MINOR semver oidc-provider updates. // NOTICE: You may disable this notice and these potentially breaking updates by acknowledging the current draft version. See https://github.com/panva/node-oidc-provider/tree/v7.3.0/docs/README.md#features new Provider('http://localhost:3000', { features: { @@ -1886,6 +1886,153 @@ _**default value**_: } ``` +### features.richAuthorizationRequests + +[`RFC9396`](https://www.rfc-editor.org/rfc/rfc9396.html) - OAuth 2.0 Rich Authorization Requests + +Enables the use of `authorization_details` parameter for the authorization and token endpoints to enable issuing Access Tokens with fine-grained authorization data. + + +_**default value**_: +```js +{ + ack: undefined, + enabled: false, + rarForAuthorizationCode: [Function: rarForAuthorizationCode], // see expanded details below + rarForCodeResponse: [Function: rarForCodeResponse], // see expanded details below + rarForIntrospectionResponse: [Function: rarForIntrospectionResponse], // see expanded details below + rarForRefreshTokenResponse: [Function: rarForRefreshTokenResponse], // see expanded details below + types: {} +} +``` + +
(Click to expand) features.richAuthorizationRequests options details
+ + +#### rarForAuthorizationCode + +Function used to transform the requested and granted RAR details that are then stored in the authorization code. Return array of details or undefined. + + +_**default value**_: +```js +rarForAuthorizationCode(ctx) { + // decision points: + // - ctx.oidc.client + // - ctx.oidc.resourceServers + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the authorization request) + // - ctx.oidc.grant.rar (authorization_details granted) + throw new Error('features.richAuthorizationRequests.rarForAuthorizationCode not implemented'); +} +``` + +#### rarForCodeResponse + +Function used to transform transform the requested and granted RAR details to be returned in the Access Token Response as authorization_details as well as assigned to the issued Access Token. Return array of details or undefined. + + +_**default value**_: +```js +rarForCodeResponse(ctx, resourceServer) { + // decision points: + // - ctx.oidc.client + // - resourceServer + // - ctx.oidc.authorizationCode.rar (previously returned from rarForAuthorizationCode) + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the body params in the Access Token Request) + // - ctx.oidc.grant.rar (authorization_details granted) + throw new Error('features.richAuthorizationRequests.rarForCodeResponse not implemented'); +} +``` + +#### rarForIntrospectionResponse + +Function used to transform transform the requested and granted RAR details to be returned in the Access Token Response as authorization_details as well as assigned to the issued Access Token. Return array of details or undefined. + + +_**default value**_: +```js +rarForIntrospectionResponse(ctx, token) { + // decision points: + // - ctx.oidc.client + // - token.kind + // - token.rar + // - ctx.oidc.grant.rar + throw new Error('features.richAuthorizationRequests.rarForIntrospectionResponse not implemented'); +} +``` + +#### rarForRefreshTokenResponse + +Function used to transform transform the requested and granted RAR details to be returned in the Access Token Response as authorization_details as well as assigned to the issued Access Token. Return array of details or undefined. + + +_**default value**_: +```js +rarForRefreshTokenResponse(ctx, resourceServer) { + // decision points: + // - ctx.oidc.client + // - resourceServer + // - ctx.oidc.refreshToken.rar (previously returned from rarForAuthorizationCode and later assigned to the refresh token) + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the body params in the Access Token Request) + // - ctx.oidc.grant.rar + throw new Error('features.richAuthorizationRequests.rarForRefreshTokenResponse not implemented'); +} +``` + +#### types + +Supported authorization details type identifiers. + + + +_**default value**_: +```js +{} +``` +
(Click to expand) https://www.rfc-editor.org/rfc/rfc9396.html#appendix-A.3 +
+ +```js +import { z } from 'zod'; +const TaxData = z + .object({ + duration_of_access: z.number().int().positive(), + locations: z.array(z.literal('https://taxservice.govehub.no.example.com')).length(1), + actions: z.array(z.literal('read_tax_declaration')).length(1), + periods: z + .array( + z.coerce + .number() + .max(new Date().getFullYear() - 1) + .min(1997) + ) + .min(1), + tax_payer_id: z.string().min(1), + }) + .strict(); +const configuration = { + features: { + richAuthorizationRequests: { + enabled: true, + // ... + types: { + tax_data: { + validate(ctx, detail, client) { + const { success: valid, error } = TaxData.parse(detail); + if (!valid) { + throw new InvalidAuthorizationDetails() + } + } + } + } + } + } +} +``` +
+ +
+ ### features.rpInitiatedLogout [`OIDC RP-Initiated Logout 1.0`](https://openid.net/specs/openid-connect-rpinitiated-1_0-final.html) @@ -2691,6 +2838,17 @@ new Prompt( return Check.NO_NEED_TO_PROMPT; }, ({ oidc }) => ({ missingResourceScopes: oidc[missingResourceScopes] })), + + // checks authorization_details + new Check('rar_prompt', 'authorization_details were requested', (ctx) => { + const { oidc } = ctx; + + if (oidc.params.authorization_details && (!oidc.result || !('consent' in oidc.result))) { + return Check.REQUEST_PROMPT; + } + + return Check.NO_NEED_TO_PROMPT; + }, ({ oidc }) => ({ rar: JSON.parse(oidc.params.authorization_details) })), ) ] ``` diff --git a/example/routes/koa.js b/example/routes/koa.js index fe9f0120f..8914ed0e3 100644 --- a/example/routes/koa.js +++ b/example/routes/koa.js @@ -183,6 +183,11 @@ export default (provider) => { grant.addResourceScope(indicator, scope.join(' ')); } } + if (details.rar) { + for (const rar of details.rar) { + grant.addRar(rar); + } + } grantId = await grant.save(); diff --git a/example/views/_layout.ejs b/example/views/_layout.ejs index cc88be966..88e73c434 100644 --- a/example/views/_layout.ejs +++ b/example/views/_layout.ejs @@ -178,6 +178,12 @@ padding-top: 0.3em; } + li > pre { + font-size: 12px; + font-family: Fixed, monospace; + margin: 0px; + } + button { cursor: pointer; } diff --git a/example/views/interaction.ejs b/example/views/interaction.ejs index 32b0d6d28..00c6d9f80 100644 --- a/example/views/interaction.ejs +++ b/example/views/interaction.ejs @@ -3,7 +3,7 @@
    -<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes].filter(Boolean).length === 0) { %> +<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes, details.rar].filter(Boolean).length === 0) { %>
  • the client is asking you to confirm previously given authorization
  • <% } %> @@ -39,6 +39,16 @@ <% } %> <% } %> +<% rar = details.rar %> +<% if (rar) { %> +
  • authorization_details:
  • +
      + <% for (const { type, ...detail } of details.rar) { %> +
    • <%= JSON.stringify({ type, ...detail }, null, 4) %>
    • + <% } %> +
    +<% } %> + <% if (params.scope && params.scope.includes('offline_access')) { %>
  • the client is asking to have offline access to this authorization diff --git a/lib/actions/authorization/index.js b/lib/actions/authorization/index.js index 68e5825b9..382165a7e 100644 --- a/lib/actions/authorization/index.js +++ b/lib/actions/authorization/index.js @@ -5,6 +5,7 @@ import paramsMiddleware from '../../shared/assemble_params.js'; import sessionMiddleware from '../../shared/session.js'; import instance from '../../helpers/weak_cache.js'; import { PARAM_LIST } from '../../consts/index.js'; +import checkRar from '../../shared/check_rar.js'; import checkResource from '../../shared/check_resource.js'; import getTokenAuth from '../../shared/token_auth.js'; @@ -52,6 +53,7 @@ import backchannelRequestResponse from './backchannel_request_response.js'; import checkCibaContext from './check_ciba_context.js'; import checkDpopJkt from './check_dpop_jkt.js'; import checkExtraParams from './check_extra_params.js'; +import unsupportedRar from './unsupported_rar.js'; const A = 'authorization'; const R = 'resume'; @@ -71,6 +73,7 @@ export default function authorizationAction(provider, endpoint) { claimsParameter, dPoP, resourceIndicators, + richAuthorizationRequests, webMessageResponseMode, }, extraParams, @@ -92,6 +95,10 @@ export default function authorizationAction(provider, endpoint) { rejectDupesMiddleware = rejectDupes.bind(undefined, { except: new Set(['resource']) }); } + if (richAuthorizationRequests.enabled) { + allowList.add('authorization_details'); + } + extraParams.forEach(Set.prototype.add.bind(allowList)); if ([DA, CV, DR, BA].includes(endpoint)) { @@ -163,12 +170,14 @@ export default function authorizationAction(provider, endpoint) { use(() => cibaRequired, BA); use(() => assignDefaults, A, DA, BA); use(() => checkPrompt, A, PAR ); - use(() => checkResource, A, DA, R, CV, DR, PAR, BA); use(() => checkScope.bind(undefined, allowList), A, DA, PAR, BA); use(() => checkOpenidScope.bind(undefined, allowList), A, DA, PAR, BA); use(() => checkRedirectUri, A, PAR ); use(() => checkPKCE, A, PAR ); use(() => checkClaims, A, DA, PAR, BA); + use(() => unsupportedRar, DA, BA); + use(() => checkRar, A, PAR ); + use(() => checkResource, A, DA, R, CV, DR, PAR, BA); use(() => checkMaxAge, A, DA, PAR, BA); use(() => checkRequestedExpiry, BA); use(() => checkCibaContext, BA); diff --git a/lib/actions/authorization/interactions.js b/lib/actions/authorization/interactions.js index 1545404ae..b109dcdb6 100644 --- a/lib/actions/authorization/interactions.js +++ b/lib/actions/authorization/interactions.js @@ -65,6 +65,7 @@ export default async function interactions(resumeRouteName, ctx, next) { .every( (resource) => !oidc.grant.getResourceScopeFiltered(resource, oidc.requestParamScopes), ) + && !oidc.params.authorization_details ) { throw new errors.AccessDenied(undefined, 'authorization request resolved without requesting interactions but no scope was granted'); } diff --git a/lib/actions/authorization/process_request_object.js b/lib/actions/authorization/process_request_object.js index db12ad912..06dfe3bab 100644 --- a/lib/actions/authorization/process_request_object.js +++ b/lib/actions/authorization/process_request_object.js @@ -87,6 +87,8 @@ export default async function processRequestObject(PARAM_LIST, rejectDupesMiddle if (PARAM_LIST.has(key)) { if (key === 'claims' && isPlainObject(value)) { acc[key] = JSON.stringify(value); + } else if (key === 'authorization_details' && Array.isArray(value)) { + acc[key] = JSON.stringify(value); } else if (Array.isArray(value)) { acc[key] = value; } else if (typeof value !== 'string') { diff --git a/lib/actions/authorization/process_response_types.js b/lib/actions/authorization/process_response_types.js index 1f635dbf9..790c09b5c 100644 --- a/lib/actions/authorization/process_response_types.js +++ b/lib/actions/authorization/process_response_types.js @@ -65,6 +65,9 @@ async function tokenHandler(ctx) { async function codeHandler(ctx) { const { expiresWithSession, + features: { + richAuthorizationRequests, + }, } = instance(ctx.oidc.provider).configuration(); const { grant } = ctx.oidc; @@ -89,6 +92,10 @@ async function codeHandler(ctx) { dpopJkt: ctx.oidc.params.dpop_jkt, }); + if (richAuthorizationRequests.enabled) { + code.rar = await richAuthorizationRequests.rarForAuthorizationCode(ctx); + } + if (Object.keys(code.claims).length === 0) { delete code.claims; } diff --git a/lib/actions/authorization/pushed_authorization_request_response.js b/lib/actions/authorization/pushed_authorization_request_response.js index ab532d1b9..4a248aa18 100644 --- a/lib/actions/authorization/pushed_authorization_request_response.js +++ b/lib/actions/authorization/pushed_authorization_request_response.js @@ -28,6 +28,10 @@ export default async function pushedAuthorizationRequestResponse(ctx, next) { payload.claims = JSON.parse(payload.claims); } + if (payload.authorization_details) { + payload.authorization_details = JSON.parse(payload.authorization_details); + } + request = new UnsecuredJWT(payload) .setIssuedAt(now) .setIssuer(ctx.oidc.client.clientId) diff --git a/lib/actions/authorization/unsupported_rar.js b/lib/actions/authorization/unsupported_rar.js new file mode 100644 index 000000000..155563794 --- /dev/null +++ b/lib/actions/authorization/unsupported_rar.js @@ -0,0 +1,9 @@ +import { InvalidRequest } from '../../helpers/errors.js'; + +export default async function unsupportedRar(ctx, next) { + if (ctx.oidc.params.authorization_details !== undefined) { + throw new InvalidRequest(`authorization_details is unsupported at the ${ctx.oidc.route}_endpoint`); + } + + return next(); +} diff --git a/lib/actions/discovery.js b/lib/actions/discovery.js index fadd631a6..3af64d557 100644 --- a/lib/actions/discovery.js +++ b/lib/actions/discovery.js @@ -35,7 +35,7 @@ export default function discovery(ctx, next) { token_endpoint: ctx.oidc.urlFor('token'), }; - const { pushedAuthorizationRequests, requestObjects } = features; + const { pushedAuthorizationRequests, requestObjects, richAuthorizationRequests } = features; ctx.body.id_token_signing_alg_values_supported = config.idTokenSigningAlgValues; if (features.encryption.enabled) { @@ -137,6 +137,10 @@ export default function discovery(ctx, next) { : undefined; } + if (richAuthorizationRequests.enabled) { + ctx.body.authorization_details_types_supported = Object.keys(richAuthorizationRequests.types); + } + defaults(ctx.body, config.discovery); return next(); diff --git a/lib/actions/grants/authorization_code.js b/lib/actions/grants/authorization_code.js index bd681b5d7..091879849 100644 --- a/lib/actions/grants/authorization_code.js +++ b/lib/actions/grants/authorization_code.js @@ -7,6 +7,7 @@ import filterClaims from '../../helpers/filter_claims.js'; import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js'; import resolveResource from '../../helpers/resolve_resource.js'; import epochTime from '../../helpers/epoch_time.js'; +import checkRar from '../../shared/check_rar.js'; const gty = 'authorization_code'; @@ -19,6 +20,7 @@ export const handler = async function authorizationCodeHandler(ctx, next) { userinfo, mTLS: { getCertificate }, resourceIndicators, + richAuthorizationRequests, dPoP: { allowReplay }, }, } = instance(ctx.oidc.provider).configuration(); @@ -147,6 +149,7 @@ export const handler = async function authorizationCodeHandler(ctx, next) { at.setThumbprint('jkt', dPoP.thumbprint); } + await checkRar(ctx, () => {}); const resource = await resolveResource(ctx, code, { userinfo, resourceIndicators }); if (resource) { @@ -159,6 +162,10 @@ export const handler = async function authorizationCodeHandler(ctx, next) { at.scope = grant.getOIDCScopeFiltered(code.scopes); } + if (richAuthorizationRequests.enabled && at.resourceServer) { + at.rar = await richAuthorizationRequests.rarForCodeResponse(ctx, at.resourceServer); + } + ctx.oidc.entity('AccessToken', at); const accessToken = await at.save(); @@ -180,6 +187,7 @@ export const handler = async function authorizationCodeHandler(ctx, next) { scope: code.scope, sessionUid: code.sessionUid, sid: code.sid, + rar: code.rar, }); if (ctx.oidc.client.clientAuthMethod === 'none') { @@ -228,8 +236,9 @@ export const handler = async function authorizationCodeHandler(ctx, next) { expires_in: at.expiration, id_token: idToken, refresh_token: refreshToken, - scope: at.scope, + scope: code.scope ? at.scope : (at.scope || undefined), token_type: at.tokenType, + authorization_details: at.rar, }; await next(); diff --git a/lib/actions/grants/ciba.js b/lib/actions/grants/ciba.js index 33dc0907f..54ad6f12f 100644 --- a/lib/actions/grants/ciba.js +++ b/lib/actions/grants/ciba.js @@ -15,11 +15,15 @@ const { InvalidGrant, } = errors; -const gty = 'ciba'; +export const gty = 'ciba'; export const handler = async function cibaHandler(ctx, next) { presence(ctx, 'auth_req_id'); + if (ctx.oidc.params.authorization_details) { + throw new errors.InvalidRequest('authorization_details is unsupported for this grant_type'); + } + const { issueRefreshToken, conformIdTokenClaims, @@ -229,7 +233,7 @@ export const handler = async function cibaHandler(ctx, next) { expires_in: at.expiration, id_token: idToken, refresh_token: refreshToken, - scope: at.scope, + scope: request.scope ? at.scope : (at.scope || undefined), token_type: at.tokenType, }; diff --git a/lib/actions/grants/client_credentials.js b/lib/actions/grants/client_credentials.js index 745eaa560..af7f26a88 100644 --- a/lib/actions/grants/client_credentials.js +++ b/lib/actions/grants/client_credentials.js @@ -1,5 +1,7 @@ import instance from '../../helpers/weak_cache.js'; -import { InvalidGrant, InvalidTarget, InvalidScope } from '../../helpers/errors.js'; +import { + InvalidGrant, InvalidTarget, InvalidScope, InvalidRequest, +} from '../../helpers/errors.js'; import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js'; import checkResource from '../../shared/check_resource.js'; import epochTime from '../../helpers/epoch_time.js'; @@ -17,6 +19,10 @@ export const handler = async function clientCredentialsHandler(ctx, next) { const dPoP = await dpopValidate(ctx); + if (ctx.oidc.params.authorization_details) { + throw new InvalidRequest('authorization_details is unsupported for this grant_type'); + } + await checkResource(ctx, () => {}); const scopes = ctx.oidc.params.scope ? [...new Set(ctx.oidc.params.scope.split(' '))] : []; @@ -77,7 +83,7 @@ export const handler = async function clientCredentialsHandler(ctx, next) { access_token: value, expires_in: token.expiration, token_type: token.tokenType, - scope: token.scope, + scope: token.scope || undefined, }; await next(); diff --git a/lib/actions/grants/device_code.js b/lib/actions/grants/device_code.js index 0bc5f16a1..1791db88c 100644 --- a/lib/actions/grants/device_code.js +++ b/lib/actions/grants/device_code.js @@ -15,11 +15,15 @@ const { InvalidGrant, } = errors; -const gty = 'device_code'; +export const gty = 'device_code'; export const handler = async function deviceCodeHandler(ctx, next) { presence(ctx, 'device_code'); + if (ctx.oidc.params.authorization_details) { + throw new errors.InvalidRequest('authorization_details is unsupported for this grant_type'); + } + const { issueRefreshToken, conformIdTokenClaims, @@ -226,7 +230,7 @@ export const handler = async function deviceCodeHandler(ctx, next) { expires_in: at.expiration, id_token: idToken, refresh_token: refreshToken, - scope: at.scope, + scope: code.scope ? at.scope : (at.scope || undefined), token_type: at.tokenType, }; diff --git a/lib/actions/grants/refresh_token.js b/lib/actions/grants/refresh_token.js index 5bfaaecd3..dbb2a06eb 100644 --- a/lib/actions/grants/refresh_token.js +++ b/lib/actions/grants/refresh_token.js @@ -1,5 +1,5 @@ import difference from '../../helpers/_/difference.js'; -import { InvalidGrant, InvalidScope } from '../../helpers/errors.js'; +import { InvalidRequest, InvalidGrant, InvalidScope } from '../../helpers/errors.js'; import presence from '../../helpers/validate_presence.js'; import instance from '../../helpers/weak_cache.js'; import revoke from '../../helpers/revoke.js'; @@ -9,6 +9,15 @@ import filterClaims from '../../helpers/filter_claims.js'; import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js'; import resolveResource from '../../helpers/resolve_resource.js'; import epochTime from '../../helpers/epoch_time.js'; +import checkRar from '../../shared/check_rar.js'; + +import { gty as cibaGty } from './ciba.js'; +import { gty as deviceCodeGty } from './device_code.js'; + +function rarSupported(token) { + const [origin] = token.gty.split(' '); + return origin !== cibaGty && origin !== deviceCodeGty; +} const gty = 'refresh_token'; @@ -25,6 +34,7 @@ export const handler = async function refreshTokenHandler(ctx, next) { mTLS: { getCertificate }, dPoP: { allowReplay }, resourceIndicators, + richAuthorizationRequests, }, } = conf; @@ -127,6 +137,10 @@ export const handler = async function refreshTokenHandler(ctx, next) { throw new InvalidGrant('refresh token already used'); } + if (ctx.oidc.params.authorization_details && !rarSupported(refreshToken)) { + throw new InvalidRequest('authorization_details is unsupported for this refresh token'); + } + if ( rotateRefreshToken === true || (typeof rotateRefreshToken === 'function' && await rotateRefreshToken(ctx)) @@ -151,6 +165,7 @@ export const handler = async function refreshTokenHandler(ctx, next) { scope: refreshToken.scope, sessionUid: refreshToken.sessionUid, sid: refreshToken.sid, + rar: refreshToken.rar, 'x5t#S256': refreshToken['x5t#S256'], jkt: refreshToken.jkt, }); @@ -186,6 +201,7 @@ export const handler = async function refreshTokenHandler(ctx, next) { } const scope = ctx.oidc.params.scope ? ctx.oidc.requestParamScopes : refreshToken.scopes; + await checkRar(ctx, () => {}); const resource = await resolveResource( ctx, refreshToken, @@ -206,6 +222,10 @@ export const handler = async function refreshTokenHandler(ctx, next) { at.scope = grant.getOIDCScopeFiltered(scope); } + if (richAuthorizationRequests.enabled && at.resourceServer) { + at.rar = await richAuthorizationRequests.rarForRefreshTokenResponse(ctx, at.resourceServer); + } + ctx.oidc.entity('AccessToken', at); const accessToken = await at.save(); @@ -240,8 +260,9 @@ export const handler = async function refreshTokenHandler(ctx, next) { expires_in: at.expiration, id_token: idToken, refresh_token: refreshTokenValue, - scope: at.scope, + scope: refreshToken.scope ? at.scope : (at.scope || undefined), token_type: at.tokenType, + authorization_details: at.rar, }; await next(); diff --git a/lib/actions/introspection.js b/lib/actions/introspection.js index d7eaf5cdc..4c4c87324 100644 --- a/lib/actions/introspection.js +++ b/lib/actions/introspection.js @@ -15,9 +15,11 @@ export default function introspectionAction(provider) { const PARAM_LIST = new Set(['token', 'token_type_hint', ...authParams]); const configuration = instance(provider).configuration(); const { - pairwiseIdentifier, features: { + pairwiseIdentifier, + features: { introspection: { allowedPolicy }, jwtIntrospection, + richAuthorizationRequests, }, } = configuration; const { grantTypeHandlers } = instance(provider); @@ -185,7 +187,9 @@ export default function introspectionAction(provider) { iss: provider.issuer, jti: token.jti !== params.token ? token.jti : undefined, aud: token.aud, - scope: token.scope, + authorization_details: token.rar + ? await richAuthorizationRequests.rarForIntrospectionResponse(ctx, token) : undefined, + scope: token.scope || undefined, cnf: token.isSenderConstrained() ? {} : undefined, token_type: token.kind !== 'RefreshToken' ? token.tokenType : undefined, }); diff --git a/lib/consts/client_attributes.js b/lib/consts/client_attributes.js index a3e33fa24..231bc3035 100644 --- a/lib/consts/client_attributes.js +++ b/lib/consts/client_attributes.js @@ -44,6 +44,7 @@ const DEFAULT = { subject_type: 'public', tls_client_certificate_bound_access_tokens: false, token_endpoint_auth_method: 'client_secret_basic', + authorization_details_types: [], }; const REQUIRED = [ @@ -71,6 +72,7 @@ const ARYS = [ 'request_uris', 'response_types', 'response_modes', + 'authorization_details_types', ]; const STRING = [ @@ -122,6 +124,7 @@ const STRING = [ 'request_uris', 'response_types', 'response_modes', + 'authorization_details_types', ]; const WHEN = { diff --git a/lib/helpers/client_schema.js b/lib/helpers/client_schema.js index e8cf62ae5..3c08c6dc5 100644 --- a/lib/helpers/client_schema.js +++ b/lib/helpers/client_schema.js @@ -145,6 +145,10 @@ export default function getSchema(provider) { RECOGNIZED_METADATA.push('dpop_bound_access_tokens'); } + if (features.richAuthorizationRequests.enabled) { + RECOGNIZED_METADATA.push('authorization_details_types'); + } + instance(provider).RECOGNIZED_METADATA = RECOGNIZED_METADATA; const ENUM = { @@ -162,6 +166,8 @@ export default function getSchema(provider) { response_types: () => configuration.responseTypes, response_modes: () => [...instance(provider).responseModes.keys()], subject_type: () => configuration.subjectTypes, + authorization_details_types: + () => Object.keys(configuration.features.richAuthorizationRequests.types), token_endpoint_auth_method: (metadata) => { if (metadata.subject_type === 'pairwise') { for (const grant of ['urn:ietf:params:oauth:grant-type:device_code', 'urn:openid:params:grant-type:ciba']) { diff --git a/lib/helpers/combined_scope.js b/lib/helpers/combined_scope.js index 7d063e771..2bdcac8b7 100644 --- a/lib/helpers/combined_scope.js +++ b/lib/helpers/combined_scope.js @@ -3,11 +3,13 @@ export default (grant, requestParamScopes, resourceServers) => { grant.getOIDCScopeFiltered(requestParamScopes) .split(' ') + .filter(Boolean) .forEach(Set.prototype.add.bind(combinedScope)); for (const resourceServer of Object.values(resourceServers)) { grant.getResourceScopeFiltered(resourceServer.identifier(), requestParamScopes) .split(' ') + .filter(Boolean) .forEach(Set.prototype.add.bind(combinedScope)); } diff --git a/lib/helpers/configuration.js b/lib/helpers/configuration.js index 949239c39..6c0aeda24 100644 --- a/lib/helpers/configuration.js +++ b/lib/helpers/configuration.js @@ -77,6 +77,7 @@ class Configuration { this.checkAuthMethods(); this.checkTTL(); this.checkCibaDeliveryModes(); + this.checkRichAuthorizationRequests(); delete this.cookies.long.maxAge; delete this.cookies.long.expires; @@ -100,6 +101,23 @@ class Configuration { } } + checkRichAuthorizationRequests() { + if (this.features.richAuthorizationRequests.enabled) { + if (!isPlainObject(this.features.richAuthorizationRequests.types)) { + throw new TypeError('features.richAuthorizationRequests.types must be an object'); + } + + for (const [k, v] of Object.entries(this.features.richAuthorizationRequests.types)) { + if (!isPlainObject(v)) { + throw new TypeError('features.richAuthorizationRequests.types attribute values must be objects'); + } + if (typeof v.validate !== 'function' || !['Function', 'AsyncFunction'].includes(v.validate.constructor.name)) { + throw new TypeError(`features.richAuthorizationRequests.types['${k}'].validate must be a function`); + } + } + } + } + registerExtraParamsValidations() { if (!isPlainObject(this.extraParams)) { return; @@ -385,6 +403,10 @@ class Configuration { ) { throw new TypeError('registration policies are only available in conjuction with adapter-backed initial access tokens'); } + + if (features.richAuthorizationRequests.enabled && !features.resourceIndicators.enabled) { + throw new TypeError('richAuthorizationRequests is only available in conjuction with enabled resourceIndicators'); + } } checkTTL() { @@ -531,7 +553,7 @@ class Configuration { attention.info(` - ${name} (This is an ${type}. URL: ${url}. Acknowledging this feature's implemented version can be done with the string '${version}')`); } }); - attention.info('Breaking changes between draft version updates may occur and these will be published as MINOR semver oidc-provider updates.'); + attention.info('Breaking changes between experimental feature updates may occur and these will be published as MINOR semver oidc-provider updates.'); attention.info('You may disable this notice and these potentially breaking updates by acknowledging the current draft version. See the documentation for more details.'); if (throwDraft) { diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index 84d8e4bbb..b85a6fc58 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -923,7 +923,7 @@ function makeDefaults() { * the provider instance will fail to instantiate if a new version of oidc-provider bundles * newer version of the RFC with breaking changes in it. * - * example: Acknowledging a draft / experimental feature + * example: Acknowledging an experimental feature * * ```js * new Provider('http://localhost:3000', { @@ -937,7 +937,7 @@ function makeDefaults() { * // The above code produces this NOTICE * // NOTICE: The following draft features are enabled and their implemented version not acknowledged * // NOTICE: - OpenID Connect Back-Channel Logout 1.0 - draft 06 (OIDF AB/Connect Working Group draft. URL: https://openid.net/specs/openid-connect-backchannel-1_0-06.html) - * // NOTICE: Breaking changes between draft version updates may occur and these will be published as MINOR semver oidc-provider updates. + * // NOTICE: Breaking changes between experimental feature updates may occur and these will be published as MINOR semver oidc-provider updates. * // NOTICE: You may disable this notice and these potentially breaking updates by acknowledging the current draft version. See https://github.com/panva/node-oidc-provider/tree/v7.3.0/docs/README.md#features * * new Provider('http://localhost:3000', { @@ -1581,6 +1581,132 @@ function makeDefaults() { rotateRegistrationAccessToken: true, }, + /* + * features.richAuthorizationRequests + * + * title: [`RFC9396`](https://www.rfc-editor.org/rfc/rfc9396.html) - OAuth 2.0 Rich Authorization Requests + * + * description: Enables the use of `authorization_details` parameter for the authorization and token + * endpoints to enable issuing Access Tokens with fine-grained authorization data. + */ + richAuthorizationRequests: { + enabled: false, + ack: undefined, + /** + * features.richAuthorizationRequests.types + * + * description: Supported authorization details type identifiers. + * + * example: https://www.rfc-editor.org/rfc/rfc9396.html#appendix-A.3 + * + * ```js + * import { z } from 'zod'; + * + * const TaxData = z + * .object({ + * duration_of_access: z.number().int().positive(), + * locations: z.array(z.literal('https://taxservice.govehub.no.example.com')).length(1), + * actions: z.array(z.literal('read_tax_declaration')).length(1), + * periods: z + * .array( + * z.coerce + * .number() + * .max(new Date().getFullYear() - 1) + * .min(1997) + * ) + * .min(1), + * tax_payer_id: z.string().min(1), + * }) + * .strict(); + * + * const configuration = { + * features: { + * richAuthorizationRequests: { + * enabled: true, + * // ... + * types: { + * tax_data: { + * validate(ctx, detail, client) { + * const { success: valid, error } = TaxData.parse(detail); + * if (!valid) { + * throw new InvalidAuthorizationDetails() + * } + * } + * } + * } + * } + * } + * } + * ``` + */ + types: {}, + /* + * features.richAuthorizationRequests.rarForAuthorizationCode + * + * description: Function used to transform the requested and granted RAR details that are then stored + * in the authorization code. Return array of details or undefined. + */ + rarForAuthorizationCode(ctx) { + // decision points: + // - ctx.oidc.client + // - ctx.oidc.resourceServers + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the authorization request) + // - ctx.oidc.grant.rar (authorization_details granted) + mustChange('features.richAuthorizationRequests.rarForAuthorizationCode', 'transform the requested and granted RAR details to be passed in the authorization code'); + throw new Error('features.richAuthorizationRequests.rarForAuthorizationCode not implemented'); + }, + /* + * features.richAuthorizationRequests.rarForCodeResponse + * + * description: Function used to transform transform the requested and granted RAR details to be + * returned in the Access Token Response as authorization_details as well as assigned to the + * issued Access Token. Return array of details or undefined. + */ + rarForCodeResponse(ctx, resourceServer) { + // decision points: + // - ctx.oidc.client + // - resourceServer + // - ctx.oidc.authorizationCode.rar (previously returned from rarForAuthorizationCode) + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the body params in the Access Token Request) + // - ctx.oidc.grant.rar (authorization_details granted) + mustChange('features.richAuthorizationRequests.rarForCodeResponse', 'transform the requested and granted RAR details to be returned in the Access Token Response as authorization_details as well as assigned to the issued Access Token'); + throw new Error('features.richAuthorizationRequests.rarForCodeResponse not implemented'); + }, + /* + * features.richAuthorizationRequests.rarForRefreshTokenResponse + * + * description: Function used to transform transform the requested and granted RAR details to be + * returned in the Access Token Response as authorization_details as well as assigned to the + * issued Access Token. Return array of details or undefined. + */ + rarForRefreshTokenResponse(ctx, resourceServer) { + // decision points: + // - ctx.oidc.client + // - resourceServer + // - ctx.oidc.refreshToken.rar (previously returned from rarForAuthorizationCode and later assigned to the refresh token) + // - ctx.oidc.params.authorization_details (unparsed authorization_details from the body params in the Access Token Request) + // - ctx.oidc.grant.rar + mustChange('features.richAuthorizationRequests.rarForRefreshTokenResponse', 'transform the requested and granted RAR details to be returned in the Access Token Response as authorization_details as well as assigned to the issued Access Token'); + throw new Error('features.richAuthorizationRequests.rarForRefreshTokenResponse not implemented'); + }, + /* + * features.richAuthorizationRequests.rarForIntrospectionResponse + * + * description: Function used to transform transform the requested and granted RAR details to be + * returned in the Access Token Response as authorization_details as well as assigned to the + * issued Access Token. Return array of details or undefined. + */ + rarForIntrospectionResponse(ctx, token) { + // decision points: + // - ctx.oidc.client + // - token.kind + // - token.rar + // - ctx.oidc.grant.rar + mustChange('features.richAuthorizationRequests.rarForIntrospectionResponse', 'transform the token\'s stored RAR details to be returned in the Introspection Response'); + throw new Error('features.richAuthorizationRequests.rarForIntrospectionResponse not implemented'); + }, + }, + /* * features.resourceIndicators * diff --git a/lib/helpers/errors.js b/lib/helpers/errors.js index 499ad0ee0..f7e68a0c6 100644 --- a/lib/helpers/errors.js +++ b/lib/helpers/errors.js @@ -132,6 +132,7 @@ export const ExpiredLoginHintToken = E('expired_login_hint_token'); export const ExpiredToken = E('expired_token'); export const InteractionRequired = E('interaction_required'); export const InvalidBindingMessage = E('invalid_binding_message'); +export const InvalidAuthorizationDetails = E('invalid_authorization_details'); export const InvalidClient = E('invalid_client'); export const InvalidDpopProof = E('invalid_dpop_proof'); export const InvalidRequestObject = E('invalid_request_object'); diff --git a/lib/helpers/features.js b/lib/helpers/features.js index 91f565cbb..290bf4191 100644 --- a/lib/helpers/features.js +++ b/lib/helpers/features.js @@ -29,6 +29,12 @@ export const DRAFTS = new Map(Object.entries({ url: 'https://tools.ietf.org/html/draft-ietf-oauth-jwt-introspection-response-10', version: ['draft-09', 'draft-10'], }, + richAuthorizationRequests: { + name: 'OAuth 2.0 Rich Authorization Requests', + type: 'IETF OAuth Working Group RFC 9396', + url: 'https://www.rfc-editor.org/rfc/rfc9396.html', + version: ['experimental-01'], + }, webMessageResponseMode: { name: 'OAuth 2.0 Web Message Response Mode - draft 01', type: 'Individual draft', diff --git a/lib/helpers/initialize_app.js b/lib/helpers/initialize_app.js index 76018ed67..0c7c0c652 100644 --- a/lib/helpers/initialize_app.js +++ b/lib/helpers/initialize_app.js @@ -88,6 +88,9 @@ export default function initializeApp() { parameters.add('resource'); dupes = new Set(['resource']); } + if (configuration.features.richAuthorizationRequests.enabled) { + parameters.add('authorization_details'); + } this.registerGrantType(grantType, handler, parameters, dupes); } }); diff --git a/lib/helpers/interaction_policy/prompts/consent.js b/lib/helpers/interaction_policy/prompts/consent.js index 35f3475ff..e7c319a07 100644 --- a/lib/helpers/interaction_policy/prompts/consent.js +++ b/lib/helpers/interaction_policy/prompts/consent.js @@ -91,4 +91,15 @@ export default () => new Prompt( return Check.NO_NEED_TO_PROMPT; }, ({ oidc }) => ({ missingResourceScopes: oidc[missingResourceScopes] })), + + // checks authorization_details + new Check('rar_prompt', 'authorization_details were requested', (ctx) => { + const { oidc } = ctx; + + if (oidc.params.authorization_details && (!oidc.result || !('consent' in oidc.result))) { + return Check.REQUEST_PROMPT; + } + + return Check.NO_NEED_TO_PROMPT; + }, ({ oidc }) => ({ rar: JSON.parse(oidc.params.authorization_details) })), ); diff --git a/lib/helpers/oidc_context.js b/lib/helpers/oidc_context.js index 3ab0d33f5..c311d55bd 100644 --- a/lib/helpers/oidc_context.js +++ b/lib/helpers/oidc_context.js @@ -199,6 +199,14 @@ export default function getContext(provider) { return this.entities.DeviceCode; } + get authorizationCode() { + return this.entities.AuthorizationCode; + } + + get refreshToken() { + return this.entities.RefreshToken; + } + get accessToken() { return this.entities.AccessToken; } diff --git a/lib/models/access_token.js b/lib/models/access_token.js index e885e8215..44e9838f5 100644 --- a/lib/models/access_token.js +++ b/lib/models/access_token.js @@ -20,6 +20,7 @@ export default (provider) => class AccessToken extends apply([ 'accountId', 'aud', + 'rar', 'claims', 'extra', 'grantId', diff --git a/lib/models/authorization_code.js b/lib/models/authorization_code.js index cd86da592..84381e896 100644 --- a/lib/models/authorization_code.js +++ b/lib/models/authorization_code.js @@ -19,6 +19,7 @@ export default (provider) => class AuthorizationCode extends apply([ ...super.IN_PAYLOAD, 'redirectUri', 'dpopJkt', + 'rar', ]; } }; diff --git a/lib/models/formats/jwt.js b/lib/models/formats/jwt.js index cbdd1cec4..7fba76985 100644 --- a/lib/models/formats/jwt.js +++ b/lib/models/formats/jwt.js @@ -98,7 +98,7 @@ export default (provider, { opaque }) => { async getValueAndPayload() { const { payload } = await opaque.getValueAndPayload.call(this); const { - aud, jti, iat, exp, scope, clientId, 'x5t#S256': x5t, jkt, extra, + aud, jti, iat, exp, scope, clientId, 'x5t#S256': x5t, jkt, extra, rar, } = payload; let { accountId: sub } = payload; @@ -121,7 +121,8 @@ export default (provider, { opaque }) => { sub: sub || clientId, iat, exp, - scope, + authorization_details: rar, + scope: scope || undefined, client_id: clientId, iss: provider.issuer, aud, diff --git a/lib/models/grant.js b/lib/models/grant.js index 403801a1e..e72363086 100644 --- a/lib/models/grant.js +++ b/lib/models/grant.js @@ -16,6 +16,7 @@ export default (provider) => class Grant extends apply([ 'resources', 'openid', 'rejected', + 'rar', ...super.IN_PAYLOAD, ]; } @@ -234,4 +235,9 @@ export default (provider) => class Grant extends apply([ const rejected = this.getRejectedOIDCClaims(); return granted.concat(rejected); } + + addRar(detail) { + this.rar ||= []; + this.rar.push(detail); + } }; diff --git a/lib/models/refresh_token.js b/lib/models/refresh_token.js index 4b98b15b0..9b69692b6 100644 --- a/lib/models/refresh_token.js +++ b/lib/models/refresh_token.js @@ -29,6 +29,7 @@ export default (provider) => class RefreshToken extends apply([ return [ ...super.IN_PAYLOAD, + 'rar', 'rotations', 'iiat', ]; diff --git a/lib/shared/check_rar.js b/lib/shared/check_rar.js new file mode 100644 index 000000000..ce3a2177b --- /dev/null +++ b/lib/shared/check_rar.js @@ -0,0 +1,75 @@ +import { InvalidAuthorizationDetails, InvalidRequest } from '../helpers/errors.js'; +import instance from '../helpers/weak_cache.js'; +import isPlainObject from '../helpers/_/is_plain_object.js'; + +export default async function checkRar(ctx, next) { + const { params, client } = ctx.oidc; + + if (params.authorization_details !== undefined) { + const { features: { richAuthorizationRequests } } = instance(ctx.oidc.provider).configuration(); + + if (richAuthorizationRequests.enabled) { + if ( + params.response_type?.split(' ').includes('code') === false + || params.response_type?.split(' ').includes('token') + || params.response_type === 'none' + ) { + throw new InvalidRequest('authorization_details parameter is not supported for this response_type'); + } + + let details; + + try { + details = JSON.parse(params.authorization_details); + } catch (err) { + throw new InvalidRequest('could not parse the authorization_details parameter JSON'); + } + + if (!Array.isArray(details)) { + throw new InvalidRequest('authorization_details parameter should be a JSON array'); + } + + if (!details.length) { + params.authorization_details = undefined; + return next(); + } + + let i = 0; + for (const detail of details) { + if (!isPlainObject(detail)) { + throw new InvalidRequest('authorization_details parameter members should be a JSON object'); + } + + if (typeof detail.type !== 'string' || !detail.type.length) { + throw new InvalidAuthorizationDetails(`authorization_details parameter members' type attribute must be a non-empty string (authorization details index ${i})`); + } + + const config = richAuthorizationRequests.types[detail.type]; + if (!config) { + throw new InvalidAuthorizationDetails(`unsupported authorization details type value (authorization details index ${i})`); + } + + if (client.authorizationDetailsTypes?.includes(detail.type) === false) { + throw new InvalidAuthorizationDetails(`authorization details type '${detail.type}' is not allowed for this client`); + } + + // check common data fields + for (const field of ['locations', 'actions', 'datatypes', 'privileges']) { + if (field in detail && (!Array.isArray(detail[field]) || detail[field].some((value) => typeof value !== 'string' || !value.length))) { + throw new InvalidAuthorizationDetails(`'${field}' must be an array of non-empty strings (authorization details index ${i})`); + } + } + if ('identifier' in detail && (typeof detail.identifier !== 'string' || !detail.identifier.length)) { + throw new InvalidAuthorizationDetails(`'identifier' must be a non-empty string (authorization details index ${i})`); + } + + await config.validate(ctx, detail, client); + + // eslint-disable-next-line no-plusplus + i++; + } + } + } + + return next(); +} diff --git a/lib/shared/check_resource.js b/lib/shared/check_resource.js index 87ca60256..dd0e46a39 100644 --- a/lib/shared/check_resource.js +++ b/lib/shared/check_resource.js @@ -8,6 +8,10 @@ const filterStatics = (ctx) => { } }; +function emptyResource(params) { + return !params.resource || (Array.isArray(params.resource) && !params.resource.length); +} + export default async function checkResource(ctx, next) { const { oidc: { @@ -31,12 +35,13 @@ export default async function checkResource(ctx, next) { if (params.resource === undefined) { params.resource = await defaultResource(ctx, client); + + if (params.authorization_details && emptyResource(params)) { + throw new InvalidTarget('resource indicator must be provided or defaulted to when Rich Authorization Requests are used'); + } } - if ( - params.scope - && (!params.resource || (Array.isArray(params.resource) && !params.resource.length)) - ) { + if (params.scope && emptyResource(params)) { filterStatics(ctx); return next(); } diff --git a/lib/views/interaction.js b/lib/views/interaction.js index fa536fae1..eeca5313a 100644 --- a/lib/views/interaction.js +++ b/lib/views/interaction.js @@ -3,7 +3,7 @@ export default `
      -<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes].filter(Boolean).length === 0) { %> +<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes, details.rar].filter(Boolean).length === 0) { %>
    • the client is asking you to confirm previously given authorization
    • <% } %> @@ -39,6 +39,16 @@ export default `