From 4ac3905aac5a32c9dc2ccba29975f68587bf77b2 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 30 Aug 2019 21:32:41 +0200 Subject: [PATCH] feat: experimental support for pushed request objects This is an implementation of https://bitbucket.org/openid/fapi/src/37426f5/Financial_API_Pushed_Request_Object.md --- certification/fapi/index.js | 3 +- certification/oidc.js | 2 +- docs/README.md | 17 ++ docs/events.md | 6 + example/my_adapter.js | 3 +- lib/actions/authorization/check_client.js | 26 ++- .../authorization/fetch_request_uri.js | 32 ++- lib/actions/authorization/index.js | 102 ++++---- .../load_pushed_request_object.js | 13 ++ lib/actions/authorization/oauth_required.js | 2 +- .../authorization/process_request_object.js | 22 +- .../authorization/process_response_types.js | 20 +- .../authorization/reject_request_and_uri.js | 14 ++ .../request_object_endpoint_params.js | 20 ++ .../request_object_remap_errors.js | 19 ++ .../authorization/request_object_response.js | 41 ++++ lib/actions/discovery.js | 56 +++-- lib/consts/index.js | 9 +- lib/helpers/configuration.js | 4 + lib/helpers/defaults.js | 12 + lib/helpers/errors.js | 2 + lib/helpers/features.js | 7 + lib/helpers/initialize_app.js | 6 + lib/models/index.js | 2 + lib/models/request_object.js | 12 + lib/provider.js | 3 + .../pushed_request_objects.config.js | 17 ++ .../pushed_request_objects.test.js | 218 ++++++++++++++++++ test/request/uri_request.test.js | 1 + test/test_helper.js | 2 +- 30 files changed, 578 insertions(+), 115 deletions(-) create mode 100644 lib/actions/authorization/load_pushed_request_object.js create mode 100644 lib/actions/authorization/reject_request_and_uri.js create mode 100644 lib/actions/authorization/request_object_endpoint_params.js create mode 100644 lib/actions/authorization/request_object_remap_errors.js create mode 100644 lib/actions/authorization/request_object_response.js create mode 100644 lib/models/request_object.js create mode 100644 test/pushed_request_objects/pushed_request_objects.config.js create mode 100644 test/pushed_request_objects/pushed_request_objects.test.js diff --git a/certification/fapi/index.js b/certification/fapi/index.js index 9afad2b60..661a4cb98 100644 --- a/certification/fapi/index.js +++ b/certification/fapi/index.js @@ -141,6 +141,7 @@ const fapi = new Server(ISSUER, { introspection: { enabled: true }, jwtIntrospection: { enabled: true }, jwtResponseModes: { enabled: true }, + pushedRequestObjects: { enabled: true }, request: { enabled: true }, requestUri: { enabled: true, requireUriRegistration: true }, revocation: { enabled: true }, @@ -177,7 +178,7 @@ if (process.env.NODE_ENV === 'production') { switch (ctx.oidc && ctx.oidc.route) { case 'discovery': { - ['token', 'introspection', 'revocation', 'userinfo'].forEach((endpoint) => { + ['token', 'introspection', 'revocation', 'userinfo', 'request_object'].forEach((endpoint) => { if (ctx.body[`${endpoint}_endpoint`].startsWith(ISSUER)) { ctx.body[`${endpoint}_endpoint`] = ctx.body[`${endpoint}_endpoint`].replace('https://', 'https://mtls.'); } diff --git a/certification/oidc.js b/certification/oidc.js index 379fb7c9e..8421ecdca 100644 --- a/certification/oidc.js +++ b/certification/oidc.js @@ -77,7 +77,7 @@ let server; switch (ctx.oidc && ctx.oidc.route) { case 'discovery': { ctx.body.mtls_endpoint_aliases = {}; - ['token', 'introspection', 'revocation', 'userinfo', 'device_authorization'].forEach((endpoint) => { + ['token', 'introspection', 'revocation', 'userinfo', 'device_authorization', 'request_object'].forEach((endpoint) => { if (!ctx.body[`${endpoint}_endpoint`]) { return; } diff --git a/docs/README.md b/docs/README.md index 79025183e..cc4248b17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ If you or your business use oidc-provider, please consider becoming a [Patron][s - [jwtResponseModes](#featuresjwtresponsemodes) - [jwtUserinfo](#featuresjwtuserinfo) - [mTLS](#featuresmtls) + - [pushedRequestObjects](#featurespushedrequestobjects) - [registration](#featuresregistration) - [registrationManagement](#featuresregistrationmanagement) - [requestObjects](#featuresrequestobjects) @@ -1220,6 +1221,21 @@ false +### features.pushedRequestObjects + +[openid-financial-api-pushed-request-object-37426f5](https://bitbucket.org/openid/fapi/src/37426f5/Financial_API_Pushed_Request_Object.md) - Pushed Request Object + +Enables the use `request_object_endpoint` defined by the Pushed Request Object draft. + + + +_**default value**_: +```js +{ + enabled: false +} +``` + ### features.registration [Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) @@ -2902,6 +2918,7 @@ _**default value**_: introspection: '/token/introspection', jwks: '/jwks', registration: '/reg', + request_object: '/request', revocation: '/token/revocation', token: '/token', userinfo: '/me' diff --git a/docs/events.md b/docs/events.md index ad3b5906d..2b707f3ff 100644 --- a/docs/events.md +++ b/docs/events.md @@ -38,6 +38,12 @@ loaded client or session. | `interaction.saved` | `(interaction)` | ... whenever interaction session is saved | | `interaction.started` | `(ctx, interaction)` | ... whenever interaction is being requested from the end-user | | `introspection.error` | `(ctx, error)` | ... whenever a handled error is encountered in the `introspection` endpoint | +| `replay_detection.destroyed` | `(token)` | ... whenever a replay detection object is destroyed | +| `replay_detection.saved` | `(token)` | ... whenever a replay detection object is saved | +| `request_object.error` | `(ctx, error)` | ... whenever a handled error is encountered in the POST `request_object` endpoint | +| `request_object.success` | `(ctx, client)` | ... with every successful request object endpoint response | +| `request_object.destroyed` | `(token)` | ... whenever a pushed request object is destroyed | +| `request_object.saved` | `(token)` | ... whenever a pushed request object is saved | | `refresh_token.consumed` | `(token)` | ... whenever a refresh token is consumed | | `refresh_token.destroyed` | `(token)` | ... whenever a refresh token is destroyed | | `refresh_token.saved` | `(token)` | ... whenever a refresh token is saved | diff --git a/example/my_adapter.js b/example/my_adapter.js index 1284530f7..1ae394e0c 100644 --- a/example/my_adapter.js +++ b/example/my_adapter.js @@ -10,7 +10,7 @@ class MyAdapter { * @constructor * @param {string} name Name of the oidc-provider model. One of "Session", "AccessToken", * "AuthorizationCode", "RefreshToken", "ClientCredentials", "Client", "InitialAccessToken", - * "RegistrationAccessToken", "DeviceCode", "Interaction" or "ReplayDetection" + * "RegistrationAccessToken", "DeviceCode", "Interaction", "ReplayDetection", or "RequestObject" * */ constructor(name) { @@ -85,6 +85,7 @@ class MyAdapter { * - errorDescription {string} - [DeviceCode only] - error_description from authnz to be returned * to the polling client * - policies {string[]} - [InitialAccessToken, RegistrationAccessToken only] array of policies + * - request {string} - [RequestObject only] Pushed Request Object value * * * when `jwt` diff --git a/lib/actions/authorization/check_client.js b/lib/actions/authorization/check_client.js index da592acdf..305726c76 100644 --- a/lib/actions/authorization/check_client.js +++ b/lib/actions/authorization/check_client.js @@ -1,8 +1,15 @@ const { strict: assert } = require('assert'); -const { InvalidClient, InvalidRequestObject } = require('../../helpers/errors'); +const { + InvalidClient, InvalidRequestObject, +} = require('../../helpers/errors'); const presence = require('../../helpers/validate_presence'); const base64url = require('../../helpers/base64url'); +const instance = require('../../helpers/weak_cache'); +const { PUSHED_REQUEST_URN } = require('../../consts'); + +const rejectRequestAndUri = require('./reject_request_and_uri'); +const loadPushedRequestObject = require('./load_pushed_request_object'); /* * Checks client_id @@ -14,15 +21,28 @@ const base64url = require('../../helpers/base64url'); */ module.exports = async function checkClient(ctx, next) { const { oidc: { params } } = ctx; + const { pushedRequestObjects } = instance(ctx.oidc.provider).configuration('features'); try { presence(ctx, 'client_id'); } catch (err) { - const { request } = params; - if (request === undefined) { + const { request_uri: requestUri } = params; + let { request } = params; + + if ( + !(pushedRequestObjects.enabled && requestUri && requestUri.startsWith(PUSHED_REQUEST_URN)) + && request === undefined + ) { throw err; } + rejectRequestAndUri(ctx, () => {}); + + if (requestUri) { + const loadedRequestObject = await loadPushedRequestObject(ctx); + request = loadedRequestObject.request; + } + const parts = request.split('.'); let decoded; let clientId; diff --git a/lib/actions/authorization/fetch_request_uri.js b/lib/actions/authorization/fetch_request_uri.js index c44f977df..7f12a52e9 100644 --- a/lib/actions/authorization/fetch_request_uri.js +++ b/lib/actions/authorization/fetch_request_uri.js @@ -1,11 +1,15 @@ const { URL } = require('url'); const { strict: assert } = require('assert'); -const { InvalidRequest, InvalidRequestUri } = require('../../helpers/errors'); +const { InvalidRequestUri } = require('../../helpers/errors'); const instance = require('../../helpers/weak_cache'); +const { PUSHED_REQUEST_URN } = require('../../consts'); const allowedSchemes = new Set(['http:', 'https:', 'urn:']); +const loadPushedRequestObject = require('./load_pushed_request_object'); +const rejectRequestAndUri = require('./reject_request_and_uri'); + /* * Validates request_uri length, protocol and its presence in client whitelist and either uses * previously cached response or loads a fresh state. Removes request_uri form the parameters and @@ -19,11 +23,10 @@ const allowedSchemes = new Set(['http:', 'https:', 'urn:']); * @throws: request_uri_not_supported */ module.exports = async function fetchRequestUri(ctx, next) { + const { pushedRequestObjects } = instance(ctx.oidc.provider).configuration('features'); const { params } = ctx.oidc; - if (params.request !== undefined && params.request_uri !== undefined) { - throw new InvalidRequest('request and request_uri parameters MUST NOT be used together'); - } + rejectRequestAndUri(ctx, () => {}); if (params.request_uri !== undefined) { let protocol; @@ -34,7 +37,14 @@ module.exports = async function fetchRequestUri(ctx, next) { throw new InvalidRequestUri('invalid request_uri scheme'); } - if (ctx.oidc.client.requestUris || protocol === 'urn:') { + let loadedRequestObject = ctx.oidc.entities.RequestObject; + if ( + !loadedRequestObject + && pushedRequestObjects.enabled + && params.request_uri.startsWith(PUSHED_REQUEST_URN) + ) { + loadedRequestObject = await loadPushedRequestObject(ctx); + } else if (!loadedRequestObject && (ctx.oidc.client.requestUris || protocol === 'urn:')) { if (!ctx.oidc.client.requestUriAllowed(params.request_uri)) { throw new InvalidRequestUri('provided request_uri is not whitelisted'); } @@ -44,13 +54,17 @@ module.exports = async function fetchRequestUri(ctx, next) { ctx.oidc.insecureRequestUri = true; } - const cache = instance(ctx.oidc.provider).requestUriCache; try { - if (protocol === 'urn:') { - params.request = await cache.resolveUrn(params.request_uri); + if (loadedRequestObject) { + params.request = loadedRequestObject.request; } else { - params.request = await cache.resolveWebUri(params.request_uri); + const cache = instance(ctx.oidc.provider).requestUriCache; + if (protocol === 'urn:') { + params.request = await cache.resolveUrn(params.request_uri); + } else { + params.request = await cache.resolveWebUri(params.request_uri); + } } assert(params.request); params.request_uri = undefined; diff --git a/lib/actions/authorization/index.js b/lib/actions/authorization/index.js index de55be37f..eb3aba7ed 100644 --- a/lib/actions/authorization/index.js +++ b/lib/actions/authorization/index.js @@ -40,17 +40,20 @@ const deviceAuthorizationResponse = require('./device_authorization_response'); const deviceAuthorizationClientId = require('./device_authorization_client_id'); const deviceUserFlow = require('./device_user_flow'); const deviceUserFlowResponse = require('./device_user_flow_response'); +const requestObjectRemapErrors = require('./request_object_remap_errors'); +const requestObjectEndpointParameters = require('./request_object_endpoint_params'); +const requestObjectResponse = require('./request_object_response'); - -const _ = undefined; const A = 'authorization'; const R = 'resume'; const DA = 'device_authorization'; const CV = 'code_verification'; const DR = 'device_resume'; -const ALL = [A, DA, R, CV, DR]; +const RO = 'request_object'; + +const authRequired = new Set([DA, RO]); -const parseBody = bodyParser.bind(_, 'application/x-www-form-urlencoded'); +const parseBody = bodyParser.bind(undefined, 'application/x-www-form-urlencoded'); module.exports = function authorizationAction(provider, endpoint) { const { @@ -73,10 +76,10 @@ module.exports = function authorizationAction(provider, endpoint) { whitelist.add('claims'); } - let rejectDupesMiddleware = rejectDupes.bind(_, {}); + let rejectDupesMiddleware = rejectDupes.bind(undefined, {}); if (resourceIndicators.enabled) { whitelist.add('resource'); - rejectDupesMiddleware = rejectDupes.bind(_, { except: new Set(['resource']) }); + rejectDupesMiddleware = rejectDupes.bind(undefined, { except: new Set(['resource']) }); } extraParams.forEach(Set.prototype.add.bind(whitelist)); @@ -101,53 +104,56 @@ module.exports = function authorizationAction(provider, endpoint) { const returnTo = /^(code|device)_/.test(endpoint) ? 'device_resume' : 'resume'; /* eslint-disable no-multi-spaces, space-in-parens */ - use(() => noCache, ...ALL); - use(() => sessionMiddleware, A, R, DR); - use(() => deviceUserFlow.bind(_, whitelist), CV, DR); - use(() => getResume.bind(_, whitelist, returnTo), R, DR); - use(() => parseBody, A, DA ); - if (endpoint === DA) { + use(() => noCache, A, DA, R, CV, DR, RO); + use(() => sessionMiddleware, A, R, DR ); + use(() => deviceUserFlow.bind(undefined, whitelist), CV, DR ); + use(() => getResume.bind(undefined, whitelist, returnTo), R, DR ); + use(() => parseBody, A, DA, RO); + if (authRequired.has(endpoint)) { const { params: authParams, middleware: tokenAuth } = getTokenAuth(provider, 'token', endpoint); - use(() => paramsMiddleware.bind(_, authParams), DA ); + use(() => paramsMiddleware.bind(undefined, authParams), DA, RO); tokenAuth.forEach((tokenAuthMiddleware) => { - use(() => tokenAuthMiddleware, DA ); + use(() => tokenAuthMiddleware, DA, RO); }); } - use(() => deviceAuthorizationClientId, DA ); - use(() => paramsMiddleware.bind(_, whitelist), A, DA ); - use(() => rejectDupesMiddleware, A, DA ); - use(() => rejectUnsupported, A, DA ); - use(() => checkClient, ...ALL); - use(() => checkClientGrantType, DA ); - use(() => checkResponseMode, A ); - use(() => fetchRequestUri, A, DA ); + use(() => deviceAuthorizationClientId, DA ); + use(() => paramsMiddleware.bind(undefined, whitelist), A, DA, RO); + use(() => requestObjectEndpointParameters, RO); + use(() => rejectDupesMiddleware, A, DA, RO); + use(() => rejectUnsupported, A, DA ); + use(() => checkClient, A, DA, R, CV, DR ); + use(() => checkClientGrantType, DA ); + use(() => checkResponseMode, A ); + use(() => fetchRequestUri, A, DA ); + use(() => requestObjectRemapErrors, RO); use(() => processRequestObject.bind( - _, whitelist, rejectDupesMiddleware, - ), A, DA ); - use(() => oneRedirectUriClients, A ); - use(() => oauthRequired, A ); - use(() => rejectRegistration, A, DA ); - use(() => oidcRequired, A ); - use(() => assignDefaults, A, DA ); - use(() => checkOpenidScope.bind(_, whitelist), A, DA ); - use(() => checkPrompt, A, DA ); - use(() => checkResponseType, A ); - use(() => checkScope.bind(_, whitelist), A, DA ); - use(() => checkRedirectUri, A ); - use(() => checkWebMessageUri, A ); - use(() => checkResource, A, DA ); - use(() => checkPKCE, A, DA ); - use(() => checkClaims, A, DA ); - use(() => checkMaxAge, A, DA ); - use(() => checkIdTokenHint, A, DA ); - use(() => authorizationEmit, A, R, CV, DR); - use(() => assignClaims, A, R, CV, DR); - use(() => loadAccount, A, R, CV, DR); - use(() => interactions.bind(_, returnTo), A, R, CV, DR); - use(() => respond, A, R ); - use(() => processResponseTypes, A, R ); - use(() => deviceAuthorizationResponse, DA ); - use(() => deviceUserFlowResponse, CV, DR); + undefined, whitelist, rejectDupesMiddleware, + ), A, DA, RO); + use(() => oneRedirectUriClients, A, RO); + use(() => oauthRequired, A, RO); + use(() => rejectRegistration, A, DA, RO); + use(() => oidcRequired, A, RO); + use(() => assignDefaults, A, DA ); + use(() => checkOpenidScope.bind(undefined, whitelist), A, DA, RO); + use(() => checkPrompt, A, DA, RO); + use(() => checkResponseType, A, RO); + use(() => checkScope.bind(undefined, whitelist), A, DA, RO); + use(() => checkRedirectUri, A, RO); + use(() => checkWebMessageUri, A, RO); + use(() => checkResource, A, DA, RO); + use(() => checkPKCE, A, DA, RO); + use(() => checkClaims, A, DA, RO); + use(() => checkMaxAge, A, DA, RO); + use(() => checkIdTokenHint, A, DA, RO); + use(() => authorizationEmit, A, R, CV, DR ); + use(() => assignClaims, A, R, CV, DR ); + use(() => loadAccount, A, R, CV, DR ); + use(() => interactions.bind(undefined, returnTo), A, R, CV, DR ); + use(() => respond, A, R ); + use(() => processResponseTypes, A, R ); + use(() => deviceAuthorizationResponse, DA ); + use(() => deviceUserFlowResponse, CV, DR ); + use(() => requestObjectResponse, RO); /* eslint-enable no-multi-spaces, space-in-parens */ return stack; diff --git a/lib/actions/authorization/load_pushed_request_object.js b/lib/actions/authorization/load_pushed_request_object.js new file mode 100644 index 000000000..4a4c12333 --- /dev/null +++ b/lib/actions/authorization/load_pushed_request_object.js @@ -0,0 +1,13 @@ +const { PUSHED_REQUEST_URN } = require('../../consts'); +const { InvalidRequestUri } = require('../../helpers/errors'); + +module.exports = async function loadPushedRequestObject(ctx) { + const { params } = ctx.oidc; + const [, id] = params.request_uri.split(PUSHED_REQUEST_URN); + const requestObject = await ctx.oidc.provider.RequestObject.find(id); + if (!requestObject) { + throw new InvalidRequestUri('request_uri is invalid or expired'); + } + ctx.oidc.entity('RequestObject', requestObject); + return requestObject; +}; diff --git a/lib/actions/authorization/oauth_required.js b/lib/actions/authorization/oauth_required.js index 0d2283890..468760389 100644 --- a/lib/actions/authorization/oauth_required.js +++ b/lib/actions/authorization/oauth_required.js @@ -7,7 +7,7 @@ const presence = require('../../helpers/validate_presence'); */ module.exports = function oauthRequired(ctx, next) { // Validate: required oauth params - presence(ctx, 'response_type'); + presence(ctx, 'response_type', 'client_id'); return next(); }; diff --git a/lib/actions/authorization/process_request_object.js b/lib/actions/authorization/process_request_object.js index 5cae5d8e0..a42aee6aa 100644 --- a/lib/actions/authorization/process_request_object.js +++ b/lib/actions/authorization/process_request_object.js @@ -117,12 +117,16 @@ module.exports = async function processRequestObject(PARAM_LIST, rejectDupesMidd throw new InvalidRequestObject('request client_id must equal the one in request parameters'); } - if (client.requestObjectSigningAlg && client.requestObjectSigningAlg !== alg) { - throw new InvalidRequestObject('the preregistered alg must be used in request or request_uri'); - } + const pushedRequestObject = 'RequestObject' in ctx.oidc.entities; + + if (!(alg === 'none' && (pushedRequestObject || ctx.oidc.route === 'request_object'))) { + if (client.requestObjectSigningAlg && client.requestObjectSigningAlg !== alg) { + throw new InvalidRequestObject('the preregistered alg must be used in request or request_uri'); + } - if (!conf('requestObjectSigningAlgValues').includes(alg)) { - throw new InvalidRequestObject('unsupported signed request alg'); + if (!conf('requestObjectSigningAlgValues').includes(alg)) { + throw new InvalidRequestObject('unsupported signed request alg'); + } } const opts = { @@ -178,7 +182,7 @@ module.exports = async function processRequestObject(PARAM_LIST, rejectDupesMidd throw new InvalidRequestObject(`could not validate Request Object (${err.message})`); } - if (payload.jti && payload.exp && payload.iss) { + if (ctx.oidc.route !== 'request_object' && payload.jti && payload.exp && payload.iss) { const unique = await ctx.oidc.provider.ReplayDetection.unique( payload.iss, payload.jti, payload.exp, ); @@ -189,7 +193,11 @@ module.exports = async function processRequestObject(PARAM_LIST, rejectDupesMidd } } - if (trusted) { + if (pushedRequestObject) { + await ctx.oidc.entities.RequestObject.destroy(); + } + + if (trusted || (pushedRequestObject && client.tokenEndpointAuthMethod !== 'none')) { ctx.oidc.signed = Object.keys(request); // TODO: in v7.x rename to "trusted" } else if (ctx.oidc.insecureRequestUri) { throw new InvalidRequestObject('Request Object from insecure request_uri must be signed and/or symmetrically encrypted'); diff --git a/lib/actions/authorization/process_response_types.js b/lib/actions/authorization/process_response_types.js index 370badbd2..6d31e6ac5 100644 --- a/lib/actions/authorization/process_response_types.js +++ b/lib/actions/authorization/process_response_types.js @@ -125,7 +125,7 @@ async function idTokenHandler(ctx) { */ module.exports = async function processResponseTypes(ctx) { const responses = ctx.oidc.params.response_type.split(' '); - const res = Object.assign({}, ...await Promise.all(responses.map((responseType) => { + const response = Object.assign({}, ...await Promise.all(responses.map((responseType) => { switch (responseType) { case 'code': return codeHandler(ctx); @@ -138,21 +138,21 @@ module.exports = async function processResponseTypes(ctx) { } }))); - if (res.access_token && res.id_token) { - res.id_token.set('at_hash', res.access_token); + if (response.access_token && response.id_token) { + response.id_token.set('at_hash', response.access_token); } - if (res.code && res.id_token) { - res.id_token.set('c_hash', res.code); + if (response.code && response.id_token) { + response.id_token.set('c_hash', response.code); } - if (ctx.oidc.params.state && res.id_token) { - res.id_token.set('s_hash', ctx.oidc.params.state); + if (ctx.oidc.params.state && response.id_token) { + response.id_token.set('s_hash', ctx.oidc.params.state); } - if (res.id_token) { - res.id_token = await res.id_token.issue({ use: 'idtoken' }); + if (response.id_token) { + response.id_token = await response.id_token.issue({ use: 'idtoken' }); } - return res; + return response; }; diff --git a/lib/actions/authorization/reject_request_and_uri.js b/lib/actions/authorization/reject_request_and_uri.js new file mode 100644 index 000000000..7a05850b1 --- /dev/null +++ b/lib/actions/authorization/reject_request_and_uri.js @@ -0,0 +1,14 @@ +const { InvalidRequest } = require('../../helpers/errors'); + +/* + * Rejects when request and request_uri are used together. + * + * @throws: invalid_request + */ +module.exports = function rejectRequestAndUri(ctx, next) { + if (ctx.oidc.params.request !== undefined && ctx.oidc.params.request_uri !== undefined) { + throw new InvalidRequest('request and request_uri parameters MUST NOT be used together'); + } + + return next(); +}; diff --git a/lib/actions/authorization/request_object_endpoint_params.js b/lib/actions/authorization/request_object_endpoint_params.js new file mode 100644 index 000000000..2540d74f2 --- /dev/null +++ b/lib/actions/authorization/request_object_endpoint_params.js @@ -0,0 +1,20 @@ +const presence = require('../../helpers/validate_presence'); + +/* + * Ignores all parameters but `request` during `request_object_endpoint` calls + * + * @throws: invalid_request + */ +module.exports = function requestObjectEndpointParameters(ctx, next) { + presence(ctx, 'request'); + Object.assign( + ctx.oidc.params, + Object.entries(ctx.oidc.params) + .reduce((acc, [key, value]) => { + acc[key] = key === 'request' ? value : undefined; + return acc; + }, {}), + ); + + return next(); +}; diff --git a/lib/actions/authorization/request_object_remap_errors.js b/lib/actions/authorization/request_object_remap_errors.js new file mode 100644 index 000000000..f6e18ac02 --- /dev/null +++ b/lib/actions/authorization/request_object_remap_errors.js @@ -0,0 +1,19 @@ +const { OIDCProviderError } = require('../../helpers/errors'); + +/* + * Remaps the Request Object Endpoint errors thrown in downstream middlewares + * + * @throws: invalid_request_object + */ +module.exports = async function requestObjectRemapErrors(ctx, next) { + try { + await next(); + } catch (err) { + if (err instanceof OIDCProviderError) { + err.message = 'invalid_request_object'; + err.error = 'invalid_request_object'; + } + + throw err; + } +}; diff --git a/lib/actions/authorization/request_object_response.js b/lib/actions/authorization/request_object_response.js new file mode 100644 index 000000000..4378ad53c --- /dev/null +++ b/lib/actions/authorization/request_object_response.js @@ -0,0 +1,41 @@ +const debug = require('debug')('oidc-provider:request_object:success'); + +const { PUSHED_REQUEST_URN } = require('../../consts'); +const epochTime = require('../../helpers/epoch_time'); +const JWT = require('../../helpers/jwt'); + +/* + * Remaps the Request Object Endpoint errors thrown in downstream middlewares + * + * @throws: invalid_request_object + */ +module.exports = async function requestObjectResponse(ctx, next) { + const { request } = ctx.oidc.body; + const now = epochTime(); + let { payload: { exp } } = JWT.decode(request); + let ttl = exp - now; + + if (!ttl) { + ttl = 300; + exp = now + ttl; + } + + const requestObject = new ctx.oidc.provider.RequestObject({ request }); + + const id = await requestObject.save(ttl); + + ctx.oidc.entity('RequestObject', requestObject); + + ctx.status = 201; + ctx.body = { + iss: ctx.oidc.provider.issuer, + aud: ctx.oidc.client.clientId, + exp, + request_uri: `${PUSHED_REQUEST_URN}${id}`, + }; + + ctx.oidc.provider.emit('request_object.success', ctx, ctx.oidc.client); + debug('request object saved client_id=%s request_uri=%s', ctx.body.aud, ctx.body.request_uri); + + return next(); +}; diff --git a/lib/actions/discovery.js b/lib/actions/discovery.js index 3c67c432c..c8bf25006 100644 --- a/lib/actions/discovery.js +++ b/lib/actions/discovery.js @@ -7,25 +7,29 @@ const { DYNAMIC_SCOPE_LABEL } = require('../consts'); module.exports = function discovery(ctx, next) { const config = instance(ctx.oidc.provider).configuration(); + const { features } = config; ctx.body = { acr_values_supported: config.acrValues.size ? [...config.acrValues] : undefined, authorization_endpoint: ctx.oidc.urlFor('authorization'), - claims_parameter_supported: config.features.claimsParameter.enabled, + device_authorization_endpoint: features.deviceFlow.enabled ? ctx.oidc.urlFor('device_authorization') : undefined, + claims_parameter_supported: features.claimsParameter.enabled, claims_supported: [...config.claimsSupported], code_challenge_methods_supported: config.pkceMethods, end_session_endpoint: ctx.oidc.urlFor('end_session'), + check_session_iframe: features.sessionManagement.enabled ? ctx.oidc.urlFor('check_session') : undefined, grant_types_supported: [...config.grantTypes], id_token_signing_alg_values_supported: config.idTokenSigningAlgValues, issuer: ctx.oidc.issuer, jwks_uri: ctx.oidc.urlFor('jwks'), - registration_endpoint: config.features.registration.enabled ? ctx.oidc.urlFor('registration') : undefined, + registration_endpoint: features.registration.enabled ? ctx.oidc.urlFor('registration') : undefined, request_object_signing_alg_values_supported: - config.features.requestObjects.request || config.features.requestObjects.requestUri + features.requestObjects.request || features.requestObjects.requestUri ? config.requestObjectSigningAlgValues : undefined, - request_parameter_supported: config.features.requestObjects.request, - request_uri_parameter_supported: config.features.requestObjects.requestUri, - require_request_uri_registration: config.features.requestObjects.requestUri && config.features.requestObjects.requireUriRegistration ? true : undefined, + request_parameter_supported: features.requestObjects.request, + request_uri_parameter_supported: features.requestObjects.requestUri, + require_request_uri_registration: features.requestObjects.requestUri && features.requestObjects.requireUriRegistration ? true : undefined, + request_object_endpoint: features.pushedRequestObjects.enabled ? ctx.oidc.urlFor('request_object') : undefined, response_modes_supported: ['form_post', 'fragment', 'query'], response_types_supported: config.responseTypes, scopes_supported: [...config.scopes].concat([...config.dynamicScopes].map((s) => s[DYNAMIC_SCOPE_LABEL]).filter(Boolean)), @@ -35,91 +39,83 @@ module.exports = function discovery(ctx, next) { token_endpoint: ctx.oidc.urlFor('token'), }; - if (config.features.userinfo.enabled) { + if (features.userinfo.enabled) { ctx.body.userinfo_endpoint = ctx.oidc.urlFor('userinfo'); - if (config.features.jwtUserinfo.enabled) { + if (features.jwtUserinfo.enabled) { ctx.body.userinfo_signing_alg_values_supported = config.userinfoSigningAlgValues; } } - if (config.features.webMessageResponseMode.enabled) { + if (features.webMessageResponseMode.enabled) { ctx.body.response_modes_supported.push('web_message'); } - if (config.features.jwtResponseModes.enabled) { + if (features.jwtResponseModes.enabled) { ctx.body.response_modes_supported.push('jwt'); ctx.body.response_modes_supported.push('query.jwt'); ctx.body.response_modes_supported.push('fragment.jwt'); ctx.body.response_modes_supported.push('form_post.jwt'); - if (config.features.webMessageResponseMode.enabled) { + if (features.webMessageResponseMode.enabled) { ctx.body.response_modes_supported.push('web_message.jwt'); } ctx.body.authorization_signing_alg_values_supported = config.authorizationSigningAlgValues; } - if (config.features.introspection.enabled) { + if (features.introspection.enabled) { ctx.body.introspection_endpoint = ctx.oidc.urlFor('introspection'); ctx.body.introspection_endpoint_auth_methods_supported = [...config.introspectionEndpointAuthMethods]; ctx.body.introspection_endpoint_auth_signing_alg_values_supported = config.introspectionEndpointAuthSigningAlgValues; } - if (config.features.jwtIntrospection.enabled) { + if (features.jwtIntrospection.enabled) { ctx.body.introspection_signing_alg_values_supported = config.introspectionSigningAlgValues; } - if (config.features.revocation.enabled) { + if (features.revocation.enabled) { ctx.body.revocation_endpoint = ctx.oidc.urlFor('revocation'); ctx.body.revocation_endpoint_auth_methods_supported = [...config.revocationEndpointAuthMethods]; ctx.body.revocation_endpoint_auth_signing_alg_values_supported = config.revocationEndpointAuthSigningAlgValues; } - if (config.features.encryption.enabled) { + if (features.encryption.enabled) { ctx.body.id_token_encryption_alg_values_supported = config.idTokenEncryptionAlgValues; ctx.body.id_token_encryption_enc_values_supported = config.idTokenEncryptionEncValues; - if (config.features.jwtUserinfo.enabled) { + if (features.jwtUserinfo.enabled) { ctx.body.userinfo_encryption_alg_values_supported = config.userinfoEncryptionAlgValues; ctx.body.userinfo_encryption_enc_values_supported = config.userinfoEncryptionEncValues; } - if (config.features.jwtIntrospection.enabled) { + if (features.jwtIntrospection.enabled) { ctx.body.introspection_encryption_alg_values_supported = config.introspectionEncryptionAlgValues; ctx.body.introspection_encryption_enc_values_supported = config.introspectionEncryptionEncValues; } - if (config.features.jwtResponseModes.enabled) { + if (features.jwtResponseModes.enabled) { ctx.body.authorization_encryption_alg_values_supported = config.authorizationEncryptionAlgValues; ctx.body.authorization_encryption_enc_values_supported = config.authorizationEncryptionEncValues; } - if (config.features.requestObjects.request || config.features.requestObjects.requestUri) { + if (features.requestObjects.request || features.requestObjects.requestUri) { ctx.body.request_object_encryption_alg_values_supported = config.requestObjectEncryptionAlgValues; ctx.body.request_object_encryption_enc_values_supported = config.requestObjectEncryptionEncValues; } } - if (config.features.sessionManagement.enabled) { - ctx.body.check_session_iframe = ctx.oidc.urlFor('check_session'); - } - - if (config.features.backchannelLogout.enabled) { + if (features.backchannelLogout.enabled) { ctx.body.backchannel_logout_supported = true; ctx.body.backchannel_logout_session_supported = true; } - if (config.features.frontchannelLogout.enabled) { + if (features.frontchannelLogout.enabled) { ctx.body.frontchannel_logout_supported = true; ctx.body.frontchannel_logout_session_supported = true; } - if (config.features.deviceFlow.enabled) { - ctx.body.device_authorization_endpoint = ctx.oidc.urlFor('device_authorization'); - } - - if (config.features.mTLS.enabled && config.features.mTLS.certificateBoundAccessTokens) { + if (features.mTLS.enabled && features.mTLS.certificateBoundAccessTokens) { ctx.body.tls_client_certificate_bound_access_tokens = true; } diff --git a/lib/consts/index.js b/lib/consts/index.js index 6a85bfc6b..f370e5a3e 100644 --- a/lib/consts/index.js +++ b/lib/consts/index.js @@ -3,10 +3,13 @@ const DEV_KEYSTORE = require('./dev_keystore'); const CLIENT_ATTRIBUTES = require('./client_attributes'); const JWA = require('./jwa'); +const PUSHED_REQUEST_URN = 'urn:ietf:params:oauth:request_uri:'; + module.exports = { - DEV_KEYSTORE, - PARAM_LIST, CLIENT_ATTRIBUTES, - JWA, + DEV_KEYSTORE, DYNAMIC_SCOPE_LABEL: Symbol('dynamic_scope_label'), + JWA, + PARAM_LIST, + PUSHED_REQUEST_URN, }; diff --git a/lib/helpers/configuration.js b/lib/helpers/configuration.js index ce2eaf9ee..56c1e52ef 100644 --- a/lib/helpers/configuration.js +++ b/lib/helpers/configuration.js @@ -363,6 +363,10 @@ class Configuration { checkDependantFeatures() { const { features } = this; + if (features.pushedRequestObjects.enabled && !features.requestObjects.requestUri) { + throw new Error('pushedRequestObjects is only available in conjuction with requestObjects.requestUri'); + } + if (features.jwtIntrospection.enabled && !features.introspection.enabled) { throw new Error('jwtIntrospection is only available in conjuction with introspection'); } diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index c836ff237..6c9524a8d 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -821,6 +821,17 @@ const DEFAULTS = { */ jwtResponseModes: { enabled: false }, + /* + * features.pushedRequestObjects + * + * title: [openid-financial-api-pushed-request-object-37426f5](https://bitbucket.org/openid/fapi/src/37426f5/Financial_API_Pushed_Request_Object.md) - Pushed Request Object + * + * description: Enables the use `request_object_endpoint` defined by the Pushed Request Object + * draft. + * + */ + pushedRequestObjects: { enabled: false }, + /* * features.registration * @@ -1591,6 +1602,7 @@ const DEFAULTS = { revocation: '/token/revocation', token: '/token', userinfo: '/me', + request_object: '/request', }, diff --git a/lib/helpers/errors.js b/lib/helpers/errors.js index 29a7829ee..251d6bdea 100644 --- a/lib/helpers/errors.js +++ b/lib/helpers/errors.js @@ -15,6 +15,8 @@ class OIDCProviderError extends Error { } } +module.exports.OIDCProviderError = OIDCProviderError; + class InvalidToken extends OIDCProviderError { constructor(detail) { super(401, 'invalid_token'); diff --git a/lib/helpers/features.js b/lib/helpers/features.js index 195a6801c..8319e1624 100644 --- a/lib/helpers/features.js +++ b/lib/helpers/features.js @@ -62,6 +62,13 @@ const DRAFTS = new Map(Object.entries({ url: 'https://openid.net/specs/openid-financial-api-jarm-wd-02.html', version: [1, 2], }, + // TODO: push this to README.md once published by IETF + pushedRequestObjects: { + name: 'Pushed Request Object', + type: 'OIDF FAPI WG draft', + url: 'https://bitbucket.org/openid/fapi/src/37426f5/Financial_API_Pushed_Request_Object.md', + version: '37426f5', + }, resourceIndicators: { name: 'Resource Indicators for OAuth 2.0 - draft 05', type: 'IETF OAuth Working Group draft', diff --git a/lib/helpers/initialize_app.js b/lib/helpers/initialize_app.js index f5e14c577..cd341dfbe 100644 --- a/lib/helpers/initialize_app.js +++ b/lib/helpers/initialize_app.js @@ -205,6 +205,12 @@ module.exports = function initializeApp() { get('device_resume', `${routes.code_verification}/:user_code/:uid`, error(this, 'device_resume.error'), ...deviceResume); } + if (configuration.features.pushedRequestObjects.enabled) { + const pushedRequestObjects = getAuthorization(this, 'request_object'); + post('request_object', routes.request_object, error(this, 'request_object.error'), CORS.client, ...pushedRequestObjects); + options('cors.request_object', routes.request_object, CORS.client); + } + if (configuration.features.devInteractions.enabled) { const interaction = getInteraction(this); diff --git a/lib/models/index.js b/lib/models/index.js index d5ee2c523..78e9f859f 100644 --- a/lib/models/index.js +++ b/lib/models/index.js @@ -8,6 +8,7 @@ const getDeviceCode = require('./device_code'); const getIdToken = require('./id_token'); const getInitialAccessToken = require('./initial_access_token'); const getInteraction = require('./interaction'); +const getRequestObject = require('./request_object'); const getRefreshToken = require('./refresh_token'); const getRegistrationAccessToken = require('./registration_access_token'); const getReplayDetection = require('./replay_detection'); @@ -24,6 +25,7 @@ module.exports = { getIdToken, getInitialAccessToken, getInteraction, + getRequestObject, getRefreshToken, getRegistrationAccessToken, getReplayDetection, diff --git a/lib/models/request_object.js b/lib/models/request_object.js new file mode 100644 index 000000000..6e19d21e9 --- /dev/null +++ b/lib/models/request_object.js @@ -0,0 +1,12 @@ +const instance = require('../helpers/weak_cache'); + +const hasFormat = require('./mixins/has_format'); + +module.exports = (provider) => class RequestObject extends hasFormat(provider, 'RequestObject', instance(provider).BaseModel) { + static get IN_PAYLOAD() { + return [ + ...super.IN_PAYLOAD, + 'request', + ]; + } +}; diff --git a/lib/provider.js b/lib/provider.js index f7a7606a6..d8a9507bc 100644 --- a/lib/provider.js +++ b/lib/provider.js @@ -105,6 +105,7 @@ class Provider extends events.EventEmitter { instance(this).RegistrationAccessToken = models.getRegistrationAccessToken(this); instance(this).ReplayDetection = models.getReplayDetection(this); instance(this).DeviceCode = models.getDeviceCode(this); + instance(this).RequestObject = models.getRequestObject(this); instance(this).OIDCContext = getContext(this); const { pathname } = url.parse(this.issuer); instance(this).mountPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; @@ -306,6 +307,8 @@ class Provider extends events.EventEmitter { get DeviceCode() { return instance(this).DeviceCode; } + get RequestObject() { return instance(this).RequestObject; } + get ReplayDetection() { return instance(this).ReplayDetection; } get requestUriCache() { return instance(this).requestUriCache; } diff --git a/test/pushed_request_objects/pushed_request_objects.config.js b/test/pushed_request_objects/pushed_request_objects.config.js new file mode 100644 index 000000000..2de3a8031 --- /dev/null +++ b/test/pushed_request_objects/pushed_request_objects.config.js @@ -0,0 +1,17 @@ +const cloneDeep = require('lodash/cloneDeep'); + +const config = cloneDeep(require('../default.config')); + +config.features = { + pushedRequestObjects: { enabled: true }, +}; + +module.exports = { + config, + clients: [{ + client_id: 'client', + client_secret: 'secret', + request_object_signing_alg: 'HS256', + redirect_uris: ['https://rp.example.com/cb'], + }], +}; diff --git a/test/pushed_request_objects/pushed_request_objects.test.js b/test/pushed_request_objects/pushed_request_objects.test.js new file mode 100644 index 000000000..07c5e172d --- /dev/null +++ b/test/pushed_request_objects/pushed_request_objects.test.js @@ -0,0 +1,218 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const JWT = require('../../lib/helpers/jwt'); +const bootstrap = require('../test_helper'); +const Provider = require('../../lib'); + +describe('Pushed Request Object', () => { + before(bootstrap(__dirname)); + const route = '/request'; + + before(async function () { + const client = await this.provider.Client.find('client'); + this.key = client.keystore.get({ alg: 'HS256' }); + }); + + describe('discovery', () => { + it('extends the well known config', function () { + return this.agent.get('/.well-known/openid-configuration') + .expect((response) => { + expect(response.body).to.have.property('request_object_endpoint'); + }); + }); + }); + + it('can only be enabled with request objects', () => { + expect(() => { + new Provider('http://localhost', { // eslint-disable-line no-new + features: { + pushedRequestObjects: { enabled: true }, + requestObjects: { + request: false, + requestUri: false, + }, + }, + }); + }).to.throw('pushedRequestObjects is only available in conjuction with requestObjects.requestUri'); + }); + + describe('Request Object Endpoint', () => { + it('populates ctx.oidc.entities', function (done) { + this.provider.use(this.assertOnce((ctx) => { + expect(ctx.oidc.entities).to.have.keys('Client', 'RequestObject'); + }, done)); + + JWT.sign({ + response_type: 'code', + client_id: 'client', + }, this.key, 'HS256').then((request) => { + this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ request }) + .end(() => {}); + }); + }); + + it('stores a request object and returns a uri', async function () { + const spy = sinon.spy(); + this.provider.once('request_object.success', spy); + + await this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client', + }, this.key, 'HS256'), + }) + .expect(201) + .expect(({ body }) => { + expect(body).to.have.keys('aud', 'exp', 'iss', 'request_uri'); + expect(body).to.have.property('aud', 'client'); + expect(body).to.have.property('exp').and.is.a('number').above(Math.floor(Date.now() / 1000)); + expect(body).to.have.property('iss', this.provider.issuer); + expect(body).to.have.property('request_uri').and.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + }); + + expect(spy).to.have.property('calledOnce', true); + }); + + it('remaps request validation errors to be related to the request object', async function () { + return this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client', + redirect_uri: 'https://rp.example.com/unlisted', + }, this.key, 'HS256'), + }) + .expect(400) + .expect({ + error: 'invalid_request_object', + error_description: "redirect_uri did not match any of the client's registered redirect_uris", + }); + }); + + it('leaves non OIDCProviderError alone', async function () { + const adapterThrow = new Error('adapter throw!'); + sinon.stub(this.TestAdapter.for('RequestObject'), 'upsert').callsFake(async () => { throw adapterThrow; }); + return this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + response_type: 'code', + client_id: 'client', + }, this.key, 'HS256'), + }) + .expect(() => { + this.TestAdapter.for('RequestObject').upsert.restore(); + }) + .expect(500) + .expect({ + error: 'server_error', + error_description: 'oops! something went wrong', + }); + }); + }); + + describe('Using Pushed Request Objects', () => { + before(function () { return this.login(); }); + after(function () { return this.logout(); }); + + it('allows the request_uri to be used', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: 'client', + }, this.key, 'HS256'), + }); + + let id = request_uri.split(':'); + id = id[id.length - 1]; + + expect(await this.provider.RequestObject.find(id)).to.be.ok; + + const auth = new this.AuthorizationRequest({ + client_id: 'client', + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); + + expect(await this.provider.RequestObject.find(id)).not.to.be.ok; + }); + + it('handles expired or invalid pushed request object', async function () { + const auth = new this.AuthorizationRequest({ + request_uri: 'urn:ietf:params:oauth:request_uri:foobar', + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['error', 'error_description', 'state'])) + .expect(auth.validateState) + .expect(auth.validateClientLocation) + .expect(auth.validateError('invalid_request_uri')) + .expect(auth.validateErrorDescription('request_uri is invalid or expired')); + }); + + it('handles expired or invalid pushed request object (when no client_id in the request)', async function () { + const renderSpy = sinon.spy(i(this.provider).configuration(), 'renderError'); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + request_uri: 'urn:ietf:params:oauth:request_uri:foobar', + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(() => { + renderSpy.restore(); + }) + .expect(400) + .expect(() => { + expect(renderSpy.calledOnce).to.be.true; + const renderArgs = renderSpy.args[0]; + expect(renderArgs[1]).to.have.property('error', 'invalid_request_uri'); + expect(renderArgs[1]).to.have.property('error_description', 'request_uri is invalid or expired'); + }); + }); + + it('allows the request_uri to be used without passing client_id to the request', async function () { + const { body: { request_uri } } = await this.agent.post(route) + .auth('client', 'secret') + .type('form') + .send({ + request: await JWT.sign({ + scope: 'openid', + response_type: 'code', + client_id: 'client', + }, this.key, 'HS256'), + }); + + const auth = new this.AuthorizationRequest({ + client_id: undefined, + state: undefined, + redirect_uri: undefined, + request_uri, + }); + + return this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validatePresence(['code'])); + }); + }); +}); diff --git a/test/request/uri_request.test.js b/test/request/uri_request.test.js index afd12990b..7849fdf22 100644 --- a/test/request/uri_request.test.js +++ b/test/request/uri_request.test.js @@ -11,6 +11,7 @@ const bootstrap = require('../test_helper'); describe('request Uri features', () => { before(bootstrap(__dirname)); + beforeEach(nock.cleanAll); describe('configuration features.requestUri', () => { it('extends discovery', function () { diff --git a/test/test_helper.js b/test/test_helper.js index 643622037..937eb3d41 100644 --- a/test/test_helper.js +++ b/test/test_helper.js @@ -141,7 +141,7 @@ module.exports = function testHelper(dir, { config: base = path.basename(dir), m Object.assign(this, parameters); - this.client_id = parameters.client_id || clients[0].client_id; + this.client_id = 'client_id' in parameters ? parameters.client_id : clients[0].client_id; const c = clients.find((cl) => cl.client_id === this.client_id); this.state = 'state' in parameters ? parameters.state : Math.random().toString(); this.redirect_uri = 'redirect_uri' in parameters ? parameters.redirect_uri : parameters.redirect_uri || (c && c.redirect_uris[0]);