diff --git a/index.d.ts b/index.d.ts index a4279033..fe15618d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -477,7 +477,7 @@ interface ConfigParams { /** * Configuration parameters used for the transaction cookie. */ - transactionCookie?: Pick; + transactionCookie?: Pick & { name?: string }; /** * String value for the client's authentication method. Default is `none` when using response_type='id_token', `private_key_jwt` when using a `clientAssertionSigningKey`, otherwise `client_secret_basic`. diff --git a/lib/config.js b/lib/config.js index eb653328..d14a4feb 100644 --- a/lib/config.js +++ b/lib/config.js @@ -85,6 +85,7 @@ const paramsSchema = Joi.object({ .valid('Lax', 'Strict', 'None') .optional() .default(Joi.ref('...session.cookie.sameSite')), + name: Joi.string().optional().default('auth_verification'), }) .default() .unknown(false), diff --git a/lib/context.js b/lib/context.js index 77ee5f3a..e717bbf5 100644 --- a/lib/context.js +++ b/lib/context.js @@ -58,13 +58,8 @@ function tokenSet() { const cachedTokenSet = weakRef(session); if (!('value' in cachedTokenSet)) { - const { - id_token, - access_token, - refresh_token, - token_type, - expires_at, - } = session; + const { id_token, access_token, refresh_token, token_type, expires_at } = + session; cachedTokenSet.value = new TokenSet({ id_token, access_token, @@ -215,9 +210,8 @@ class ResponseContext { stateValue.attemptingSilentLogin = true; } - const usePKCE = options.authorizationParams.response_type.includes( - 'code' - ); + const usePKCE = + options.authorizationParams.response_type.includes('code'); if (usePKCE) { debug( 'response_type includes code, the authorization request will use PKCE' @@ -258,7 +252,7 @@ class ResponseContext { ); } - transient.store('auth_verification', req, res, { + transient.store(config.transactionCookie.name, req, res, { sameSite: options.authorizationParams.response_mode === 'form_post' ? 'None' diff --git a/middleware/auth.js b/middleware/auth.js index d4928bc1..04195f58 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -89,7 +89,7 @@ const auth = function (params) { try { const callbackParams = client.callbackParams(req); const authVerification = transient.getOnce( - 'auth_verification', + config.transactionCookie.name, req, res ); diff --git a/test/callback.tests.js b/test/callback.tests.js index f4948991..aa8fdb9b 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -30,8 +30,8 @@ const defaultConfig = { }; let server; -const generateCookies = (values) => ({ - auth_verification: JSON.stringify(values), +const generateCookies = (values, customTxnCookieName) => ({ + [customTxnCookieName || 'auth_verification']: JSON.stringify(values), }); const setup = async (params) => { @@ -287,7 +287,9 @@ describe('callback response_mode: form_post', () => { legacySameSiteCookie: false, }, cookies: { - _auth_verification: JSON.stringify({ state: '__test_state__' }), + ['_auth_verification']: JSON.stringify({ + state: '__test_state__', + }), }, body: { state: '__test_state__', @@ -324,7 +326,7 @@ describe('callback response_mode: form_post', () => { identityClaimFilter: [], }, cookies: { - _auth_verification: JSON.stringify({ + auth_verification: JSON.stringify({ state: expectedDefaultState, nonce: '__test_nonce__', }), @@ -394,6 +396,48 @@ describe('callback response_mode: form_post', () => { }); }); + it('should succeed even if custom transaction cookie name used', async () => { + let customTxnCookieName = 'CustomTxnCookie'; + const idToken = makeIdToken(); + const { + response: { statusCode, headers }, + currentUser, + tokens, + } = await setup({ + cookies: generateCookies( + { + state: expectedDefaultState, + nonce: '__test_nonce__', + }, + customTxnCookieName + ), + body: { + state: expectedDefaultState, + id_token: idToken, + }, + authOpts: { + transactionCookie: { name: customTxnCookieName }, + }, + }); + assert.equal(statusCode, 302); + assert.equal(headers.location, 'https://example.org'); + assert.ok(currentUser); + assert.equal(currentUser.sub, '__test_sub__'); + assert.equal(currentUser.nickname, '__test_nickname__'); + assert.notExists(currentUser.iat); + assert.notExists(currentUser.iss); + assert.notExists(currentUser.aud); + assert.notExists(currentUser.exp); + assert.notExists(currentUser.nonce); + assert.equal(tokens.isAuthenticated, true); + assert.equal(tokens.idToken, idToken); + assert.isUndefined(tokens.refreshToken); + assert.isUndefined(tokens.accessToken); + assert.include(tokens.idTokenClaims, { + sub: '__test_sub__', + }); + }); + it("should expose all tokens when id_token is valid and response_type is 'code id_token'", async () => { const idToken = makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ', diff --git a/test/config.tests.js b/test/config.tests.js index d7f1e503..b130b686 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -187,6 +187,7 @@ describe('get config', () => { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], transactionCookie: { sameSite: 'Strict', + name: 'auth_verification', }, session: { name: '__test_custom_session_name__', @@ -247,6 +248,7 @@ describe('get config', () => { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], transactionCookie: { sameSite: 'Lax', + name: 'auth_verification', }, }); }); @@ -266,6 +268,7 @@ describe('get config', () => { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], transactionCookie: { sameSite: 'Strict', + name: 'auth_verification', }, }); }); @@ -276,6 +279,7 @@ describe('get config', () => { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], transactionCookie: { sameSite: 'Strict', + name: 'CustomTxnCookie', }, session: { cookie: { @@ -288,6 +292,7 @@ describe('get config', () => { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], transactionCookie: { sameSite: 'Strict', + name: 'CustomTxnCookie', }, }); }); diff --git a/test/login.tests.js b/test/login.tests.js index 8daf31f3..42d4b0f3 100644 --- a/test/login.tests.js +++ b/test/login.tests.js @@ -17,22 +17,24 @@ const filterRoute = (method, path) => { r.route && r.route.path === path && r.route.methods[method.toLowerCase()]; }; -const fetchAuthCookie = (res) => { +const fetchAuthCookie = (res, txnCookieName) => { + txnCookieName = txnCookieName || 'auth_verification'; const cookieHeaders = res.headers['set-cookie']; return cookieHeaders.filter( - (header) => header.split('=')[0] === 'auth_verification' + (header) => header.split('=')[0] === txnCookieName )[0]; }; -const fetchFromAuthCookie = (res, cookieName) => { - const authCookie = fetchAuthCookie(res); +const fetchFromAuthCookie = (res, cookieName, txnCookieName) => { + txnCookieName = txnCookieName || 'auth_verification'; + const authCookie = fetchAuthCookie(res, txnCookieName); if (!authCookie) { return false; } const decodedAuthCookie = querystring.decode(authCookie); - const cookieValuePart = decodedAuthCookie.auth_verification + const cookieValuePart = decodedAuthCookie[txnCookieName] .split('; ')[0] .split('.')[0]; const authCookieParsed = JSON.parse(cookieValuePart); @@ -103,6 +105,39 @@ describe('auth', () => { assert.equal(fetchFromAuthCookie(res, 'state'), parsed.query.state); }); + it('should redirect to the authorize url for /login when txn cookie name is custom', async () => { + let customTxnCookieName = 'CustomTxnCookie'; + + server = await createServer( + auth({ + ...defaultConfig, + transactionCookie: { name: customTxnCookieName }, + }) + ); + const res = await request.get('/login', { baseUrl, followRedirect: false }); + assert.equal(res.statusCode, 302); + + const parsed = url.parse(res.headers.location, true); + assert.equal(parsed.hostname, 'op.example.com'); + assert.equal(parsed.pathname, '/authorize'); + assert.equal(parsed.query.client_id, '__test_client_id__'); + assert.equal(parsed.query.scope, 'openid profile email'); + assert.equal(parsed.query.response_type, 'id_token'); + assert.equal(parsed.query.response_mode, 'form_post'); + assert.equal(parsed.query.redirect_uri, 'https://example.org/callback'); + assert.property(parsed.query, 'nonce'); + assert.property(parsed.query, 'state'); + + assert.equal( + fetchFromAuthCookie(res, 'nonce', customTxnCookieName), + parsed.query.nonce + ); + assert.equal( + fetchFromAuthCookie(res, 'state', customTxnCookieName), + parsed.query.state + ); + }); + it('should redirect to the authorize url for any route if authRequired', async () => { server = await createServer( auth({ @@ -132,6 +167,22 @@ describe('auth', () => { assert.equal(res.statusCode, 302); }); + it('should redirect to the authorize url for any route with custom txn name if attemptSilentLogin ', async () => { + server = await createServer( + auth({ + ...defaultConfig, + authRequired: false, + attemptSilentLogin: true, + transactionCookie: { name: 'CustomTxnCookie' }, + }) + ); + const res = await request.get('/session', { + baseUrl, + followRedirect: false, + }); + assert.equal(res.statusCode, 302); + }); + it('should redirect to the authorize url for /login in code flow', async () => { server = await createServer( auth({ @@ -162,6 +213,44 @@ describe('auth', () => { assert.equal(fetchFromAuthCookie(res, 'state'), parsed.query.state); }); + it('should redirect to the authorize url for /login in code flow with custom txn cookie', async () => { + let customTxnCookieName = 'CustomTxnCookie'; + server = await createServer( + auth({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code', + }, + transactionCookie: { name: customTxnCookieName }, + }) + ); + const res = await request.get('/login', { baseUrl, followRedirect: false }); + assert.equal(res.statusCode, 302); + + const parsed = url.parse(res.headers.location, true); + + assert.equal(parsed.hostname, 'op.example.com'); + assert.equal(parsed.pathname, '/authorize'); + assert.equal(parsed.query.client_id, '__test_client_id__'); + assert.equal(parsed.query.scope, 'openid profile email'); + assert.equal(parsed.query.response_type, 'code'); + assert.equal(parsed.query.response_mode, undefined); + assert.equal(parsed.query.redirect_uri, 'https://example.org/callback'); + assert.property(parsed.query, 'nonce'); + assert.property(parsed.query, 'state'); + assert.property(res.headers, 'set-cookie'); + + assert.equal( + fetchFromAuthCookie(res, 'nonce', customTxnCookieName), + parsed.query.nonce + ); + assert.equal( + fetchFromAuthCookie(res, 'state', customTxnCookieName), + parsed.query.state + ); + }); + it('should redirect to the authorize url for /login in id_token flow', async () => { server = await createServer( auth({ @@ -297,7 +386,39 @@ describe('auth', () => { }); it('should not allow an invalid response_type', async function () { - const router = auth({ ...defaultConfig, routes: { login: false } }); + const router = auth({ + ...defaultConfig, + routes: { login: false }, + }); + router.get('/login', (req, res) => { + res.oidc.login({ + authorizationParams: { + response_type: 'invalid', + }, + }); + }); + server = await createServer(router); + + const cookieJar = request.jar(); + const res = await request.get('/login', { + cookieJar, + baseUrl, + json: true, + followRedirect: false, + }); + assert.equal(res.statusCode, 500); + assert.equal( + res.body.err.message, + 'response_type should be one of id_token, code id_token, code' + ); + }); + + it('should not allow an invalid response_type when txn cookie name custom', async function () { + const router = auth({ + ...defaultConfig, + routes: { login: false }, + transactionCookie: { name: 'CustomTxnCookie' }, + }); router.get('/login', (req, res) => { res.oidc.login({ authorizationParams: {