diff --git a/lib/actions/authorization/interactions.js b/lib/actions/authorization/interactions.js index a9df31974..79cf6065b 100644 --- a/lib/actions/authorization/interactions.js +++ b/lib/actions/authorization/interactions.js @@ -124,7 +124,7 @@ export default async function interactions(resumeRouteName, ctx, next) { const destination = await interactionUrl(ctx, interactionSession); - const disableCookies = instance(ctx.oidc.provider).configuration('cookies.disabled') === true; + const disableCookies = instance(ctx.oidc.provider).configuration('cookies.doNotSet') === true; if (!disableCookies) { ssHandler.set( ctx.oidc.cookies, diff --git a/lib/actions/authorization/process_response_types.js b/lib/actions/authorization/process_response_types.js index 790c09b5c..28b9f4fcc 100644 --- a/lib/actions/authorization/process_response_types.js +++ b/lib/actions/authorization/process_response_types.js @@ -118,6 +118,7 @@ async function codeHandler(ctx) { if (ctx.oidc.client.includeSid() || (ctx.oidc.claims.id_token && 'sid' in ctx.oidc.claims.id_token)) { code.sid = ctx.oidc.session.sidFor(ctx.oidc.client.clientId); + code.jti = ctx.oidc.session.jti; } ctx.oidc.entity('AuthorizationCode', code); @@ -153,6 +154,7 @@ async function idTokenHandler(ctx) { if (ctx.oidc.client.includeSid() || (ctx.oidc.claims.id_token && 'sid' in ctx.oidc.claims.id_token)) { idToken.set('sid', ctx.oidc.session.sidFor(ctx.oidc.client.clientId)); + idToken.set('jti', ctx.oidc.session.jti); } return { id_token: idToken }; diff --git a/lib/actions/authorization/resume.js b/lib/actions/authorization/resume.js index 213aa7bfc..535951f61 100644 --- a/lib/actions/authorization/resume.js +++ b/lib/actions/authorization/resume.js @@ -12,17 +12,11 @@ import epochTime from '../../helpers/epoch_time.js'; function getInteractionIdFromCookie(ctx) { const cookieOptions = instance(ctx.oidc.provider).configuration('cookies.short'); - - const cookieId = ssHandler.get( + return ssHandler.get( ctx.oidc.cookies, ctx.oidc.provider.cookieName('resume'), cookieOptions, ); - - if (!cookieId) { - throw new errors.SessionNotFound('authorization request has expired'); - } - return cookieId; } function getInteractionIdFromPath(ctx) { @@ -38,8 +32,13 @@ function getInteractionIdFromPath(ctx) { } function getInteractionId(ctx) { - const disableCookies = instance(ctx.oidc.provider).configuration('cookies.disabled') === true; - return disableCookies ? getInteractionIdFromPath(ctx) : getInteractionIdFromCookie(ctx); + const enableCookielessFallback = instance(ctx.oidc.provider).configuration('cookies.enableCookielessFallback') === true; + const interactionIdFromCookie = getInteractionIdFromCookie(ctx); + if (!interactionIdFromCookie && !enableCookielessFallback) { + throw new errors.SessionNotFound('authorization request has expired'); + } + // We support looking up sessions without a cookie. Look for the interaction ID on the path. + return interactionIdFromCookie || getInteractionIdFromPath(ctx); } export default async function resumeAction(allowList, resumeRouteName, ctx, next) { @@ -50,15 +49,15 @@ export default async function resumeAction(allowList, resumeRouteName, ctx, next } ctx.oidc.entity('Interaction', interactionSession); - const disableCookies = instance(ctx.oidc.provider).configuration('cookies.disabled') === true; + const enableCookielessFallback = instance(ctx.oidc.provider).configuration('cookies.enableCookielessFallback') === true; // If cookies are enabled, the cookie maxAge will serve to enforce the session TTL. // Otherwise, check the interaction's expiry: - if (disableCookies && interactionSession.exp && interactionSession.exp < epochTime()) { + if (enableCookielessFallback && interactionSession.exp && interactionSession.exp < epochTime()) { throw new errors.SessionNotFound('interaction has expired'); } - if (!disableCookies && interactionId !== interactionSession.uid) { + if (!enableCookielessFallback && interactionId !== interactionSession.uid) { throw new errors.SessionNotFound('authorization session and cookie identifier mismatch'); } @@ -106,6 +105,7 @@ export default async function resumeAction(allowList, resumeRouteName, ctx, next ctx.oidc.trusted = trusted; ctx.oidc.redirectUriCheckPerformed = true; + const disableCookies = instance(ctx.oidc.provider).configuration('cookies.doNotSet') === true; if (!disableCookies) { const cookieOptions = instance(ctx.oidc.provider).configuration('cookies.short'); const clearOpts = { diff --git a/lib/actions/end_session.js b/lib/actions/end_session.js index e0f1d2984..66cca4044 100644 --- a/lib/actions/end_session.js +++ b/lib/actions/end_session.js @@ -19,8 +19,8 @@ const randomFill = util.promisify(crypto.randomFill); export const init = [ noCache, - sessionMiddleware, parseBody, + sessionMiddleware, paramsMiddleware.bind(undefined, new Set(['id_token_hint', 'post_logout_redirect_uri', 'state', 'ui_locales', 'client_id', 'logout_hint'])), rejectDupes.bind(undefined, {}), @@ -88,7 +88,33 @@ export const init = [ const action = ctx.oidc.urlFor('end_session_confirm'); - if (ctx.oidc.session.accountId) { + const getAccountIdForContext = () => { + if (ctx.oidc.session.accountId) { + // The session already has the accountId, likely loaded from a session cookie. + return ctx.oidc.session.accountId; + } + // Likely no session cookie was passed in. + // If there is an id_token_hint in query params which is a JWT, + // use its 'sub' as the accountId. + const { params } = ctx.oidc; + // id_token_hint is an optional query parameter according to the spec for RP-initiated logout. + // If present, try to decode it as a JWT and use its subject as the accountId. + if (!params.id_token_hint) { + return undefined; + } + try { + const idTokenHint = JWT.decode(params.id_token_hint); + return idTokenHint?.payload?.sub; + } catch { + // idTokenHint is not a valid JWT. + return undefined; + } + }; + + const accountId = getAccountIdForContext(); + + const bypassConsent = await instance(ctx.oidc.provider).configuration('features.rpInitiatedLogout.bypassConsent') === true; + if (accountId && !bypassConsent) { ctx.type = 'html'; ctx.status = 200; @@ -98,6 +124,9 @@ export const init = [ formPost(ctx, action, { xsrf: secret, logout: 'yes', + // If no session cookies are set, the end_session_confirm endpoint may read the account ID from the JWT. + id_token_hint: ctx.oidc?.params?.id_token_hint, + client_id: ctx.oidc?.params?.client_id, }); } @@ -107,8 +136,8 @@ export const init = [ export const confirm = [ noCache, - sessionMiddleware, parseBody, + sessionMiddleware, paramsMiddleware.bind(undefined, new Set(['xsrf', 'logout'])), rejectDupes.bind(undefined, {}), diff --git a/lib/actions/grants/authorization_code.js b/lib/actions/grants/authorization_code.js index 091879849..52a86c66d 100644 --- a/lib/actions/grants/authorization_code.js +++ b/lib/actions/grants/authorization_code.js @@ -227,6 +227,7 @@ export const handler = async function authorizationCodeHandler(ctx, next) { token.set('nonce', code.nonce); token.set('at_hash', accessToken); token.set('sid', code.sid); + token.set('jti', code.jti); idToken = await token.issue({ use: 'idtoken' }); } diff --git a/lib/models/client.js b/lib/models/client.js index 903f909cf..d9b6fb0c2 100644 --- a/lib/models/client.js +++ b/lib/models/client.js @@ -583,6 +583,12 @@ export default function getClient(provider) { } includeSid() { + const enableCookielessFallback = instance(provider).configuration('cookies.enableCookielessFallback'); + if (enableCookielessFallback) { + // When supporting cookieless session lookup, always include the Session ID on the issued ID token, + // so that endpoints such as end_session can look up the session from there, instead of the cookie. + return true; + } return this.backchannelLogoutUri && this.backchannelLogoutSessionRequired; } diff --git a/lib/models/session.js b/lib/models/session.js index 4253bccbc..e7890ae91 100644 --- a/lib/models/session.js +++ b/lib/models/session.js @@ -4,6 +4,7 @@ import nanoid from '../helpers/nanoid.js'; import epochTime from '../helpers/epoch_time.js'; import instance from '../helpers/weak_cache.js'; import * as ssHandler from '../helpers/samesite_handler.js'; +import * as JWT from '../helpers/jwt.js'; import hasFormat from './mixins/has_format.js'; @@ -52,7 +53,42 @@ export default (provider) => class Session extends hasFormat(provider, 'Session' } } + static async getSessionFromIdTokenHint(ctx) { + try { + const clientId = ctx.oidc?.params?.client_id + || ctx.oidc?.body?.client_id + || ctx.request?.query?.client_id; + + if (!clientId) { + // We need to know the client in order to verify the ID token signature (the key lives with the client). + return undefined; + } + const client = await provider.Client.find(clientId); + if (!client) { + return undefined; + } + const idTokenJwt = ctx.oidc?.params?.id_token_hint + || ctx.oidc?.body?.id_token_hint + || ctx.request?.query?.id_token_hint; + + // Verify the signature of the ID token JWT + await provider.IdToken.validate(idTokenJwt, client); + + const decodedIdToken = JWT.decode(idTokenJwt); + const sessionId = decodedIdToken?.payload?.jti; + if (!sessionId) { + return undefined; + } + return this.find(sessionId); + } catch { + // Could not decode id_token_hint as JWT. + return undefined; + } + } + static async get(ctx) { + const cookielessFallbackEnabled = instance(provider).configuration('cookies.enableCookielessFallback') === true; + const cookies = ctx.oidc ? ctx.oidc.cookies : provider.app.createContext(ctx.req, ctx.res).cookies; cookies.secure = !cookies.secure && ctx.secure ? true : cookies.secure; @@ -70,6 +106,11 @@ export default (provider) => class Session extends hasFormat(provider, 'Session' session = await this.find(cookieSessionId); } + if (!session && cookielessFallbackEnabled) { + // Try to get the session from a query param. + session = await this.getSessionFromIdTokenHint(ctx); + } + if (!session) { if (cookieSessionId) { // underlying session was removed since we have a session id in cookie, let's assign an diff --git a/lib/provider.js b/lib/provider.js index 5ba2e7780..35f0a3f8b 100644 --- a/lib/provider.js +++ b/lib/provider.js @@ -26,15 +26,11 @@ import DPoPNonces from './helpers/dpop_nonces.js'; function getInteractionIdFromCookie(req, res) { const ctx = this.app.createContext(req, res); - const id = ssHandler.get( + return ssHandler.get( ctx.cookies, this.cookieName('interaction'), instance(this).configuration('cookies.short'), ); - if (!id) { - throw new SessionNotFound('interaction session id cookie not found'); - } - return id; } function getInteractionIdFromPath(req) { @@ -50,18 +46,22 @@ function getInteractionIdFromPath(req) { } async function getInteraction(req, res) { - const disableCookies = instance(this).configuration('cookies.disabled') === true; - const interactionId = disableCookies - ? getInteractionIdFromPath.bind(this)(req) - : getInteractionIdFromCookie.bind(this)(req, res); + const enableCookielessFallback = instance(this).configuration('cookies.enableCookielessFallback') === true; + let interactionId = getInteractionIdFromCookie.bind(this)(req, res); + if (!interactionId && !enableCookielessFallback) { + throw new SessionNotFound('interaction session id cookie not found'); + } + // We support looking up sessions without cookies. Look for the interaction ID on the path. + interactionId = getInteractionIdFromPath.bind(this)(req); + const interaction = await this.Interaction.find(interactionId); if (!interaction) { throw new SessionNotFound('interaction session not found'); } - // If cookies are enabled, the cookie maxAge will serve to enforce the session TTL. - // Otherwise, check the interaction's expiry: - if (disableCookies && interaction.exp && interaction.exp < epochTime()) { + // If cookies are required, the cookie maxAge will serve to enforce the session TTL. + // Otherwise, if cookieless session lookup is enabled, check the interaction's expiry: + if (enableCookielessFallback && interaction.exp && interaction.exp < epochTime()) { throw new SessionNotFound('interaction has expired'); } diff --git a/lib/shared/session.js b/lib/shared/session.js index 5a910fe10..d9cb40357 100644 --- a/lib/shared/session.js +++ b/lib/shared/session.js @@ -28,7 +28,7 @@ export default async function sessionHandler(ctx, next) { try { await next(); } finally { - const disableCookies = instance(ctx.oidc.provider).configuration('cookies.disabled'); + const disableCookies = instance(ctx.oidc.provider).configuration('cookies.doNotSet'); const sessionCookieName = ctx.oidc.provider.cookieName('session'); const longRegExp = new RegExp(`^${sessionCookieName}(?:\\.legacy)?(?:\\.sig)?=`); diff --git a/test/interaction/interaction.test.js b/test/interaction/interaction.test.js index 7f7cc6f9e..e11653269 100644 --- a/test/interaction/interaction.test.js +++ b/test/interaction/interaction.test.js @@ -56,7 +56,7 @@ const testInteraction = { save: sinon.stub(), destroy: sinon.stub(), iat: 1729517474, - exp: 1729521074, + exp: 4128493153, returnTo: 'http://127.0.0.1:62009/auth/test-interaction-id', prompt: { name: 'login', reasons: ['no_session'], details: {} }, params: { @@ -218,7 +218,7 @@ describe('devInteractions', () => { }); }); - context('with cookies enabled', () => { + context('with cookieless fallback disabled', () => { it('accepts the login and resumes auth', async function () { let location; await this.agent.post(`${this.url}`) @@ -237,12 +237,12 @@ describe('devInteractions', () => { .expect(303); }); }); - context('with cookies disabled', () => { + context('with cookieless fallback enabled', () => { before(async function () { - i(this.provider).configuration('cookies').disabled = true; + i(this.provider).configuration('cookies').enableCookielessFallback = true; }); after(async function () { - i(this.provider).configuration('cookies').disabled = false; + i(this.provider).configuration('cookies').enableCookielessFallback = false; sinon.restore(); }); it('should look up interaction from ID in path params', async function () { @@ -291,12 +291,12 @@ describe('devInteractions', () => { handlesInteractionSessionErrors(); - context('with cookies disabled', async () => { + context('with cookieless fallback enabled', async () => { before(async function () { - i(this.provider).configuration('cookies').disabled = true; + i(this.provider).configuration('cookies').enableCookielessFallback = true; }); after(async function () { - i(this.provider).configuration('cookies').disabled = false; + i(this.provider).configuration('cookies').enableCookielessFallback = false; sinon.restore(); }); @@ -309,9 +309,9 @@ describe('devInteractions', () => { }); }); - context('with cookies enabled', async () => { + context('with cookieless fallback disabled', async () => { before(async function () { - i(this.provider).configuration('cookies').disabled = false; + i(this.provider).configuration('cookies').enableCookielessFallback = false; }); it('should look up interaction from ID in path params', async function () { // If cookies are enabled, this should fail because the interaction ID in the cookie is not the same