Skip to content

Commit

Permalink
feat: add experimental support for RFC9396 - Rich Authorization Requests
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Feb 1, 2024
1 parent 3e8a784 commit e9fb573
Show file tree
Hide file tree
Showing 37 changed files with 600 additions and 26 deletions.
162 changes: 160 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


<a id="features-acknowledging-a-draft-experimental-feature"></a><details><summary>(Click to expand) Acknowledging a draft / experimental feature
<a id="features-acknowledging-an-experimental-feature"></a><details><summary>(Click to expand) Acknowledging an experimental feature
</summary><br>

```js
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {}
}
```
<details><summary>(Click to expand) features.richAuthorizationRequests options details</summary><br>
#### 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
{}
```
<a id="types-https-www-rfc-editor-org-rfc-rfc-9396-html-appendix-a-3"></a><details><summary>(Click to expand) https://www.rfc-editor.org/rfc/rfc9396.html#appendix-A.3
</summary><br>
```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()
}
}
}
}
}
}
}
```
</details>
</details>
### features.rpInitiatedLogout
[`OIDC RP-Initiated Logout 1.0`](https://openid.net/specs/openid-connect-rpinitiated-1_0-final.html)
Expand Down Expand Up @@ -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) })),
)
]
```
Expand Down
5 changes: 5 additions & 0 deletions example/routes/koa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions example/views/_layout.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@
padding-top: 0.3em;
}
li > pre {
font-size: 12px;
font-family: Fixed, monospace;
margin: 0px;
}
button {
cursor: pointer;
}
Expand Down
12 changes: 11 additions & 1 deletion example/views/interaction.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
</div>

<ul>
<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes].filter(Boolean).length === 0) { %>
<% if ([details.missingOIDCScope, details.missingOIDCClaims, details.missingResourceScopes, details.rar].filter(Boolean).length === 0) { %>
<li>the client is asking you to confirm previously given authorization</li>
<% } %>

Expand Down Expand Up @@ -39,6 +39,16 @@
<% } %>
<% } %>

<% rar = details.rar %>
<% if (rar) { %>
<li>authorization_details:</li>
<ul>
<% for (const { type, ...detail } of details.rar) { %>
<li><pre><%= JSON.stringify({ type, ...detail }, null, 4) %></pre></li>
<% } %>
</ul>
<% } %>

<% if (params.scope && params.scope.includes('offline_access')) { %>
<li>
the client is asking to have offline access to this authorization
Expand Down
11 changes: 10 additions & 1 deletion lib/actions/authorization/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand All @@ -71,6 +73,7 @@ export default function authorizationAction(provider, endpoint) {
claimsParameter,
dPoP,
resourceIndicators,
richAuthorizationRequests,
webMessageResponseMode,
},
extraParams,
Expand All @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/actions/authorization/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
2 changes: 2 additions & 0 deletions lib/actions/authorization/process_request_object.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
7 changes: 7 additions & 0 deletions lib/actions/authorization/process_response_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions lib/actions/authorization/unsupported_rar.js
Original file line number Diff line number Diff line change
@@ -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();
}
6 changes: 5 additions & 1 deletion lib/actions/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit e9fb573

Please sign in to comment.