Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: TECH-676 Support cookieless session lookup, including logout #7

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/actions/authorization/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/actions/authorization/process_response_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down
24 changes: 12 additions & 12 deletions lib/actions/authorization/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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');
}

Expand Down Expand Up @@ -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 = {
Expand Down
35 changes: 32 additions & 3 deletions lib/actions/end_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {}),

Expand Down Expand Up @@ -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;

Expand All @@ -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,
});
}

Expand All @@ -107,8 +136,8 @@ export const init = [

export const confirm = [
noCache,
sessionMiddleware,
parseBody,
sessionMiddleware,
paramsMiddleware.bind(undefined, new Set(['xsrf', 'logout'])),
rejectDupes.bind(undefined, {}),

Expand Down
1 change: 1 addition & 0 deletions lib/actions/grants/authorization_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
6 changes: 6 additions & 0 deletions lib/models/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
41 changes: 41 additions & 0 deletions lib/models/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
24 changes: 12 additions & 12 deletions lib/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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');
}

Expand Down
2 changes: 1 addition & 1 deletion lib/shared/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)?=`);

Expand Down
20 changes: 10 additions & 10 deletions test/interaction/interaction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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}`)
Expand All @@ -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 () {
Expand Down Expand Up @@ -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();
});

Expand All @@ -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
Expand Down