diff --git a/EXAMPLES.md b/EXAMPLES.md index aebe81db9..6659b9d33 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,6 +1,7 @@ # Examples - [Basic Setup](#basic-setup) +- [Create your own instance of the SDK](#create-your-own-instance-of-the-sdk) - [Customize handlers behavior](#customize-handlers-behavior) - [Use custom auth urls](#use-custom-auth-urls) - [Protecting a Server-Side Rendered (SSR) Page](#protecting-a-server-side-rendered-ssr-page) @@ -8,9 +9,9 @@ - [Protect an API Route](#protect-an-api-route) - [Protecting pages with Middleware](#protecting-pages-with-middleware) - [Access an External API from an API Route](#access-an-external-api-from-an-api-route) -- [Create your own instance of the SDK](#create-your-own-instance-of-the-sdk) - [Add a signup handler](#add-a-signup-handler) - [Use with Base Path and Internationalized Routing](#use-with-base-path-and-internationalized-routing) +- [Use a custom session store](#use-a-custom-session-store) All examples can be seen running in the [Kitchen Sink example app](./examples/kitchen-sink-example). @@ -81,6 +82,78 @@ export default () => { Have a look at the `basic-example` app [./examples/basic-example](./examples/basic-example). +## Create your own instance of the SDK + +When you use the named exports, the SDK creates an instance of the SDK for you and configures it with the provided environment variables. + +```js +// These named exports create and manage their own instance of the SDK configured with +// the provided `AUTH0_*` environment variables +import { + handleAuth, + handleLogin, + handleCallback, + handleLogout, + handleProfile, + withApiAuthRequired, + withPageAuthRequired, + getSession, + getAccessToken +} from '@auth0/nextjs-auth0'; +``` + +However, there are various reasons why you might want to create and manage an instance of the SDK yourself: + +- You may want to create your own instance for testing +- You may not want to use environment variables for the configuration of secrets (for example, to use CredStash or AWS's Key Management Service) +- You may be using a [custom session store](#use-a-custom-session-store) and need to provide the configuration as code. + +In this case you can use the [initAuth0](https://auth0.github.io/nextjs-auth0/modules/instance.html) method to create an instance. + +```js +// utils/auth0.js +import { initAuth0 } from '@auth0/nextjs-auth0'; + +export default initAuth0({ + secret: 'LONG_RANDOM_VALUE', + issuerBaseURL: 'https://your-tenant.auth0.com', + baseURL: 'http://localhost:3000', + clientID: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET' +}); +``` + +Now rather than using the named exports, you can use the instance methods directly. + +```js +// pages/api/auth/[...auth0].js +import auth0 from '../../../utils/auth0'; + +// Use the instance method +export default auth0.handleAuth(); +``` + +> Note: You should not use the instance methods in combination with the named exports, +> otherwise you will be creating multiple instances of the SDK. For example: + +```js +// DON'T Mix instance methods and named exports +import auth0 from '../../../utils/auth0'; +import { handleLogin } from '@auth0/nextjs-auth0'; + +export default auth0.handleAuth({ // <= instance method + async login(req, res) { + try { + // `auth0.handleAuth` and `handleLogin` will be using separate instances + // You should use `auth0.handleLogin` instead + await handleLogin(req, res); // <= named export + } catch (error) { + res.status(error.status || 400).end(error.message); + } + } +}); +``` + ## Customize handlers behavior Pass custom parameters to the auth handlers or add your own logging and error handling. @@ -324,78 +397,6 @@ See a running example of the [API route acting as a proxy to an External API](./ - Check "Allow Offline Access" in your [API Settings](https://auth0.com/docs/get-started/apis/api-settings#access-settings) - Make sure the "Refresh Token" grant is enabled in your [Application Settings](https://auth0.com/docs/get-started/applications/application-settings#grant-types) (this is the default) -## Create your own instance of the SDK - -When you use the named exports, the SDK creates an instance of the SDK for you and configures it with the provided environment variables. - -```js -// These named exports create and manage their own instance of the SDK configured with -// the provided `AUTH0_*` environment variables -import { - handleAuth, - handleLogin, - handleCallback, - handleLogout, - handleProfile, - withApiAuthRequired, - withPageAuthRequired, - getSession, - getAccessToken -} from '@auth0/nextjs-auth0'; -``` - -However, there are various reasons why you might want to create and manage an instance of the SDK yourself: - -- You may want to create your own instance for testing -- You may not want to use environment variables for the configuration of secrets (eg using CredStash or AWS's Key Management Service) - -In this case you can use the [initAuth0](https://auth0.github.io/nextjs-auth0/modules/instance.html) method to create an instance. - -```js -// utils/auth0.js -import { initAuth0 } from '@auth0/nextjs-auth0'; - -export default initAuth0({ - secret: 'LONG_RANDOM_VALUE', - issuerBaseURL: 'https://your-tenant.auth0.com', - baseURL: 'http://localhost:3000', - clientID: 'CLIENT_ID', - clientSecret: 'CLIENT_SECRET' -}); -``` - -Now rather than using the named exports, you can use the instance methods directly. - -```js -// pages/api/auth/[...auth0].js -import auth0 from '../../../utils/auth0'; - -// Use the instance method -export default auth0.handleAuth(); -``` - -> Note: You should not use the instance methods in combination with the named exports, -> otherwise you will be creating multiple instances of the SDK. For example: - -```js -// DON'T Mix instance methods and named exports -import auth0 from '../../../utils/auth0'; -import { handleLogin } from '@auth0/nextjs-auth0'; - -export default auth0.handleAuth({ - // <= instance method - async login(req, res) { - try { - // `auth0.handleAuth` and `handleLogin` will be using separate instances - // You should use `auth0.handleLogin` instead - await handleLogin(req, res); // <= named export - } catch (error) { - res.status(error.status || 400).end(error.message); - } - } -}); -``` - # Add a signup handler Pass a custom authorize parameter to the login handler in a custom route. @@ -467,3 +468,57 @@ export const getServerSideProps = (ctx) => { return withPageAuthRequired({ returnTo })(ctx); }; ``` + +## Use a custom session store + +You need to create your own instance of the SDK in code, so you can pass an instance of your session store to the SDK's configuration. + +```typescript +// lib/auth0.ts +import { SessionStore, SessionStorePayload, initAuth0 } from '@auth0/nextjs-auth0'; + +class Store implements SessionStore { + private store: KeyValueStoreLikeRedis; + constructor() { + // If you set the expiry accross the whole store use the session config, + // for example `min(config.session.rollingDuration, config.session.absoluteDuration)` + // the default is 24 hrs + this.store = new KeyValueStoreLikeRedis(); + } + async get(id) { + const val = await this.store.get(id); + return val; + } + async set(id, val) { + // To set the expiry per item, use `val.header.exp` (in secs) + const expiryMs = val.header.exp * 1000; + // Example for Redis: redis.set(id, val, { pxat: expiryMs }); + await this.store.set(id, val); + } + async delete(id) { + await this.store.delete(id); + } +} + +let auth0; + +export default () => { + if (!auth0) { + auth0 = initAuth0({ + session: { + store: new Store() + } + }); + } + return auth0; +}; +``` + +Then use your instance wherever you use the server methods of the SDK. + +```ts +// /pages/api/auth/[auth0].js +import getAuth0 from '../../../lib/auth0'; + +export default getAuth0().handleAuth(); +``` diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts index ec4d1c028..f9bff69e7 100644 --- a/src/auth0-session/config.ts +++ b/src/auth0-session/config.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from 'http'; import type { AuthorizationParameters as OidcAuthorizationParameters } from 'openid-client'; +import { SessionStore } from './session/stateful-session'; /** * Configuration properties. @@ -174,6 +175,19 @@ export interface SessionConfig { */ name: string; + /** + * By default, the session is stateless and stored in an encrypted cookie. But if you want a stateful session + * you can provide a store with `get`, `set` and `destroy` methods to store the session on the server side. + */ + store?: SessionStore; + + /** + * A function for generating a session id when using a custom session store. + * + * **IMPORTANT** You must use a suitably unique value to prevent collisions. + */ + genId?: (req: Req) => string | Promise; + /** * If you want your session duration to be rolling, resetting everytime the * user is active on your site, set this to `true`. If you want the session diff --git a/src/auth0-session/cookie-store.ts b/src/auth0-session/cookie-store.ts deleted file mode 100644 index 6a1409e4c..000000000 --- a/src/auth0-session/cookie-store.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { IncomingMessage, ServerResponse } from 'http'; -import * as jose from 'jose'; -import { CookieSerializeOptions, serialize } from 'cookie'; -import { encryption as deriveKey } from './utils/hkdf'; -import createDebug from './utils/debug'; -import { Cookies } from './utils/cookies'; -import { Config } from './config'; - -const debug = createDebug('cookie-store'); -const epoch = (): number => (Date.now() / 1000) | 0; // eslint-disable-line no-bitwise -const MAX_COOKIE_SIZE = 4096; -const alg = 'dir'; -const enc = 'A256GCM'; - -type Header = { iat: number; uat: number; exp: number }; -const notNull = (value: T | null): value is T => value !== null; -const assert = (bool: boolean, msg: string) => { - if (!bool) { - throw new Error(msg); - } -}; - -export default class CookieStore { - private keys?: Uint8Array[]; - - private chunkSize: number; - - constructor(private config: Config, private Cookies: new () => Cookies) { - const { - cookie: { transient, ...cookieConfig }, - name: sessionName - } = this.config.session; - const cookieOptions: CookieSerializeOptions = { - ...cookieConfig - }; - if (!transient) { - cookieOptions.expires = new Date(); - } - - const emptyCookie = serialize(`${sessionName}.0`, '', cookieOptions); - this.chunkSize = MAX_COOKIE_SIZE - emptyCookie.length; - } - - private async getKeys(): Promise { - if (!this.keys) { - const secret = this.config.secret; - const secrets = Array.isArray(secret) ? secret : [secret]; - this.keys = await Promise.all(secrets.map(deriveKey)); - } - return this.keys; - } - - public async encrypt(payload: jose.JWTPayload, { iat, uat, exp }: Header): Promise { - const [key] = await this.getKeys(); - return await new jose.EncryptJWT({ ...payload }).setProtectedHeader({ alg, enc, uat, iat, exp }).encrypt(key); - } - - private async decrypt(jwe: string): Promise { - const keys = await this.getKeys(); - let err; - for (const key of keys) { - try { - return await jose.jwtDecrypt(jwe, key); - } catch (e) { - err = e; - } - } - throw err; - } - - private calculateExp(iat: number, uat: number): number { - const { absoluteDuration } = this.config.session; - const { rolling, rollingDuration } = this.config.session; - - if (typeof absoluteDuration !== 'number') { - return uat + (rollingDuration as number); - } - if (!rolling) { - return iat + absoluteDuration; - } - return Math.min(uat + (rollingDuration as number), iat + absoluteDuration); - } - - public async read(req: Req): Promise<[{ [key: string]: any }?, number?]> { - const cookies = new this.Cookies().getAll(req); - const { name: sessionName, rollingDuration, absoluteDuration } = this.config.session; - - let iat: number; - let uat: number; - let exp: number; - let existingSessionValue; - - try { - if (sessionName in cookies) { - // get JWE from unchunked session cookie - debug('reading session from %s cookie', sessionName); - existingSessionValue = cookies[sessionName]; - } else if (`${sessionName}.0` in cookies) { - // get JWE from chunked session cookie - // iterate all cookie names - // match and filter for the ones that match sessionName. - // sort by chunk index - // concat - existingSessionValue = Object.entries(cookies) - .map(([cookie, value]): [string, string] | null => { - const match = cookie.match(`^${sessionName}\\.(\\d+)$`); - if (match) { - return [match[1], value as string]; - } - return null; - }) - .filter(notNull) - .sort(([a], [b]) => { - return parseInt(a, 10) - parseInt(b, 10); - }) - .map(([i, chunk]) => { - debug('reading session chunk from %s.%d cookie', sessionName, i); - return chunk; - }) - .join(''); - } - - if (existingSessionValue) { - const { protectedHeader: header, payload } = await this.decrypt(existingSessionValue); - ({ iat, uat, exp } = header as unknown as Header); - - // check that the existing session isn't expired based on options when it was established - assert(exp > epoch(), 'it is expired based on options when it was established'); - - // check that the existing session isn't expired based on current rollingDuration rules - if (rollingDuration) { - assert(uat + rollingDuration > epoch(), 'it is expired based on current rollingDuration rules'); - } - - // check that the existing session isn't expired based on current absoluteDuration rules - if (typeof absoluteDuration === 'number') { - assert(iat + absoluteDuration > epoch(), 'it is expired based on current absoluteDuration rules'); - } - - return [payload, iat]; - } - } catch (err) { - debug('error handling session %O', err); - } - - return []; - } - - public async save( - req: Req, - res: Res, - session: { [key: string]: any } | undefined | null, - createdAt?: number - ): Promise { - const { - cookie: { transient, ...cookieConfig }, - name: sessionName - } = this.config.session; - const cookieSetter = new this.Cookies(); - const cookies = cookieSetter.getAll(req); - - if (!session) { - debug('clearing all matching session cookies'); - for (const cookieName of Object.keys(cookies)) { - if (cookieName.match(`^${sessionName}(?:\\.\\d)?$`)) { - cookieSetter.clear(cookieName, cookieConfig); - cookieSetter.commit(res, this.config.session.name); - } - } - return; - } - - const uat = epoch(); - const iat = typeof createdAt === 'number' ? createdAt : uat; - const exp = this.calculateExp(iat, uat); - - const cookieOptions: CookieSerializeOptions = { - ...cookieConfig - }; - if (!transient) { - cookieOptions.expires = new Date(exp * 1000); - } - - debug('found session, creating signed session cookie(s) with name %o(.i)', sessionName); - const value = await this.encrypt(session, { iat, uat, exp }); - - const chunkCount = Math.ceil(value.length / this.chunkSize); - if (chunkCount > 1) { - debug('cookie size greater than %d, chunking', this.chunkSize); - for (let i = 0; i < chunkCount; i++) { - const chunkValue = value.slice(i * this.chunkSize, (i + 1) * this.chunkSize); - const chunkCookieName = `${sessionName}.${i}`; - cookieSetter.set(chunkCookieName, chunkValue, cookieOptions); - } - if (sessionName in cookies) { - cookieSetter.clear(sessionName, cookieConfig); - } - } else { - cookieSetter.set(sessionName, value, cookieOptions); - for (const cookieName of Object.keys(cookies)) { - if (cookieName.match(`^${sessionName}\\.\\d$`)) { - cookieSetter.clear(cookieName, cookieConfig); - } - } - } - cookieSetter.commit(res, this.config.session.name); - } -} diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts index cd463574c..d2ead312e 100644 --- a/src/auth0-session/get-config.ts +++ b/src/auth0-session/get-config.ts @@ -29,6 +29,8 @@ const paramsSchema = Joi.object({ .optional() .default(7 * 24 * 60 * 60), // 7 days, name: Joi.string().token().optional().default('appSession'), + store: Joi.object().optional(), + genId: Joi.function().maxArity(1).when(Joi.ref('store'), { then: Joi.required() }), storeIDToken: Joi.boolean().optional().default(true), cookie: Joi.object({ domain: Joi.string().optional(), diff --git a/src/auth0-session/index.ts b/src/auth0-session/index.ts index 99342ece1..fad584a89 100644 --- a/src/auth0-session/index.ts +++ b/src/auth0-session/index.ts @@ -5,7 +5,9 @@ export { IdentityProviderError, ApplicationError } from './utils/errors'; -export { default as CookieStore } from './cookie-store'; +export { StatelessSession } from './session/stateless-session'; +export { AbstractSession, SessionPayload } from './session/abstract-session'; +export { StatefulSession, SessionStore } from './session/stateful-session'; export { default as TransientStore } from './transient-store'; export { Config, SessionConfig, CookieConfig, LoginOptions, LogoutOptions, AuthorizationParameters } from './config'; export { get as getConfig, ConfigParameters, DeepPartial } from './get-config'; diff --git a/src/auth0-session/session/abstract-session.ts b/src/auth0-session/session/abstract-session.ts new file mode 100644 index 000000000..99eec9473 --- /dev/null +++ b/src/auth0-session/session/abstract-session.ts @@ -0,0 +1,125 @@ +import createDebug from '../utils/debug'; +import { CookieSerializeOptions } from 'cookie'; +import { Config } from '../config'; +import { Cookies } from '../utils/cookies'; + +const debug = createDebug('session'); + +export interface SessionPayload { + header: { + /** + * Timestamp (in secs) when the session was created. + */ + iat: number; + /** + * Timestamp (in secs) when the session was last touched. + */ + uat: number; + /** + * Timestamp (in secs) when the session expires. + */ + exp: number; + }; + + /** + * The session data. + */ + data: Session; +} + +const epoch = (): number => (Date.now() / 1000) | 0; // eslint-disable-line no-bitwise +export type Header = { iat: number; uat: number; exp: number; [propName: string]: unknown }; +const assert = (bool: boolean, msg: string) => { + if (!bool) { + throw new Error(msg); + } +}; + +export abstract class AbstractSession { + constructor(protected config: Config, protected Cookies: new () => Cookies) {} + + abstract getSession(req: Req): Promise | undefined | null>; + + abstract setSession( + req: Req, + res: Res, + session: Session, + uat: number, + iat: number, + exp: number, + cookieOptions: CookieSerializeOptions, + isNewSession: boolean + ): Promise; + + abstract deleteSession(req: Req, res: Res, cookieOptions: CookieSerializeOptions): Promise; + + public async read(req: Req): Promise<[{ [key: string]: any }?, number?]> { + const { rollingDuration, absoluteDuration } = this.config.session; + + try { + const existingSessionValue = await this.getSession(req); + + if (existingSessionValue) { + const { header, data } = existingSessionValue; + const { iat, uat, exp } = header; + + // check that the existing session isn't expired based on options when it was established + assert(exp > epoch(), 'it is expired based on options when it was established'); + + // check that the existing session isn't expired based on current rollingDuration rules + if (rollingDuration) { + assert(uat + rollingDuration > epoch(), 'it is expired based on current rollingDuration rules'); + } + + // check that the existing session isn't expired based on current absoluteDuration rules + if (typeof absoluteDuration === 'number') { + assert(iat + absoluteDuration > epoch(), 'it is expired based on current absoluteDuration rules'); + } + + return [data, iat]; + } + } catch (err) { + debug('error handling session %O', err); + } + + return []; + } + + public async save(req: Req, res: Res, session: Session | null | undefined, createdAt?: number): Promise { + const { + cookie: { transient, ...cookieConfig } + } = this.config.session; + + if (!session) { + await this.deleteSession(req, res, cookieConfig); + return; + } + + const isNewSession = typeof createdAt === 'undefined'; + const uat = epoch(); + const iat = typeof createdAt === 'number' ? createdAt : uat; + const exp = this.calculateExp(iat, uat); + + const cookieOptions: CookieSerializeOptions = { + ...cookieConfig + }; + if (!transient) { + cookieOptions.expires = new Date(exp * 1000); + } + + await this.setSession(req, res, session, uat, iat, exp, cookieOptions, isNewSession); + } + + private calculateExp(iat: number, uat: number): number { + const { absoluteDuration } = this.config.session; + const { rolling, rollingDuration } = this.config.session; + + if (typeof absoluteDuration !== 'number') { + return uat + (rollingDuration as number); + } + if (!rolling) { + return iat + absoluteDuration; + } + return Math.min(uat + (rollingDuration as number), iat + absoluteDuration); + } +} diff --git a/src/auth0-session/session/stateful-session.ts b/src/auth0-session/session/stateful-session.ts new file mode 100644 index 000000000..5cc31522e --- /dev/null +++ b/src/auth0-session/session/stateful-session.ts @@ -0,0 +1,115 @@ +import { CookieSerializeOptions } from 'cookie'; +import createDebug from '../utils/debug'; +import { Config } from '../config'; +import { Cookies } from '../utils/cookies'; +import { AbstractSession, SessionPayload } from './abstract-session'; +import { generateCookieValue, getCookieValue } from '../utils/signed-cookies'; +import { signing } from '../utils/hkdf'; + +const debug = createDebug('stateful-session'); + +export interface SessionStore { + /** + * Gets the session from the store given a session ID. + */ + get(sid: string): Promise | null | undefined>; + + /** + * Upsert a session in the store given a session ID and `SessionData`. + */ + set(sid: string, session: SessionPayload): Promise; + + /** + * Destroys the session with the given session ID. + */ + delete(sid: string): Promise; +} + +export class StatefulSession< + Req, + Res, + Session extends { [key: string]: any } = { [key: string]: any } +> extends AbstractSession { + private keys?: Uint8Array[]; + private store: SessionStore; + + constructor(protected config: Config, protected Cookies: new () => Cookies) { + super(config, Cookies); + this.store = config.session.store as SessionStore; + } + + private async getKeys(): Promise { + if (!this.keys) { + const secret = this.config.secret; + const secrets = Array.isArray(secret) ? secret : [secret]; + this.keys = await Promise.all(secrets.map(signing)); + } + return this.keys; + } + + async getSession(req: Req): Promise | undefined | null> { + const { name: sessionName } = this.config.session; + const cookies = new this.Cookies().getAll(req); + const keys = await this.getKeys(); + const sessionId = await getCookieValue(sessionName, cookies[sessionName], keys); + + if (sessionId) { + debug('reading session from %s store', sessionId); + return this.store.get(sessionId); + } + return; + } + + async setSession( + req: Req, + res: Res, + session: Session, + uat: number, + iat: number, + exp: number, + cookieOptions: CookieSerializeOptions, + isNewSession: boolean + ): Promise { + const { name: sessionName, genId } = this.config.session; + const cookieSetter = new this.Cookies(); + const cookies = cookieSetter.getAll(req); + const keys = await this.getKeys(); + let sessionId = await getCookieValue(sessionName, cookies[sessionName], keys); + + // If this is a new session created by a new login we need to remove the old session + // from the store and regenerate the session id to prevent session fixation issue. + if (sessionId && isNewSession) { + debug('regenerating session id %o to prevent session fixation', sessionId); + await this.store.delete(sessionId); + sessionId = undefined; + } + + if (!sessionId) { + sessionId = await genId!(req); + debug('generated new session id %o', sessionId); + const cookieValue = await generateCookieValue(sessionName, sessionId, keys[0]); + cookieSetter.set(sessionName, cookieValue, cookieOptions); + cookieSetter.commit(res); + } + debug('set session %o', sessionId); + await this.store.set(sessionId, { + header: { iat, uat, exp }, + data: session + }); + } + + async deleteSession(req: Req, res: Res, cookieOptions: CookieSerializeOptions): Promise { + const { name: sessionName } = this.config.session; + const cookieSetter = new this.Cookies(); + const cookies = cookieSetter.getAll(req); + const keys = await this.getKeys(); + const sessionId = await getCookieValue(sessionName, cookies[sessionName], keys); + + if (sessionId) { + debug('deleting session %o', sessionId); + cookieSetter.clear(sessionName, cookieOptions); + cookieSetter.commit(res); + await this.store.delete(sessionId); + } + } +} diff --git a/src/auth0-session/session/stateless-session.ts b/src/auth0-session/session/stateless-session.ts new file mode 100644 index 000000000..0d3d9d40a --- /dev/null +++ b/src/auth0-session/session/stateless-session.ts @@ -0,0 +1,158 @@ +import * as jose from 'jose'; +import { CookieSerializeOptions, serialize } from 'cookie'; +import createDebug from '../utils/debug'; +import { Config } from '../config'; +import { Cookies } from '../utils/cookies'; +import { encryption } from '../utils/hkdf'; +import { AbstractSession, Header, SessionPayload } from './abstract-session'; + +const debug = createDebug('stateless-session'); + +const MAX_COOKIE_SIZE = 4096; +const alg = 'dir'; +const enc = 'A256GCM'; + +const notNull = (value: T | null): value is T => value !== null; + +export class StatelessSession< + Req, + Res, + Session extends { [key: string]: any } = { [key: string]: any } +> extends AbstractSession { + private keys?: Uint8Array[]; + private chunkSize: number; + + constructor(protected config: Config, protected Cookies: new () => Cookies) { + super(config, Cookies); + const { + cookie: { transient, ...cookieConfig }, + name: sessionName + } = this.config.session; + const cookieOptions: CookieSerializeOptions = { + ...cookieConfig + }; + if (!transient) { + cookieOptions.expires = new Date(); + } + + const emptyCookie = serialize(`${sessionName}.0`, '', cookieOptions); + this.chunkSize = MAX_COOKIE_SIZE - emptyCookie.length; + } + + private async getKeys(): Promise { + if (!this.keys) { + const secret = this.config.secret; + const secrets = Array.isArray(secret) ? secret : [secret]; + this.keys = await Promise.all(secrets.map(encryption)); + } + return this.keys; + } + + public async encrypt(payload: jose.JWTPayload, { iat, uat, exp }: Header): Promise { + const [key] = await this.getKeys(); + return await new jose.EncryptJWT({ ...payload }).setProtectedHeader({ alg, enc, uat, iat, exp }).encrypt(key); + } + + private async decrypt(jwe: string): Promise { + const keys = await this.getKeys(); + let err; + for (const key of keys) { + try { + return await jose.jwtDecrypt(jwe, key); + } catch (e) { + err = e; + } + } + throw err; + } + + async getSession(req: Req): Promise | undefined | null> { + const { name: sessionName } = this.config.session; + const cookies = new this.Cookies().getAll(req); + let existingSessionValue: string | undefined; + if (sessionName in cookies) { + // get JWE from unchunked session cookie + debug('reading session from %s cookie', sessionName); + existingSessionValue = cookies[sessionName]; + } else if (`${sessionName}.0` in cookies) { + // get JWE from chunked session cookie + // iterate all cookie names + // match and filter for the ones that match sessionName. + // sort by chunk index + // concat + existingSessionValue = Object.entries(cookies) + .map(([cookie, value]): [string, string] | null => { + const match = cookie.match(`^${sessionName}\\.(\\d+)$`); + if (match) { + return [match[1], value as string]; + } + return null; + }) + .filter(notNull) + .sort(([a], [b]) => { + return parseInt(a, 10) - parseInt(b, 10); + }) + .map(([i, chunk]) => { + debug('reading session chunk from %s.%d cookie', sessionName, i); + return chunk; + }) + .join(''); + } + if (existingSessionValue) { + const { protectedHeader, payload } = await this.decrypt(existingSessionValue); + return { header: protectedHeader as unknown as Header, data: payload as Session }; + } + return; + } + + async setSession( + req: Req, + res: Res, + session: Session, + uat: number, + iat: number, + exp: number, + cookieOptions: CookieSerializeOptions + ): Promise { + const { name: sessionName } = this.config.session; + const cookieSetter = new this.Cookies(); + const cookies = cookieSetter.getAll(req); + + debug('found session, creating signed session cookie(s) with name %o(.i)', sessionName); + const value = await this.encrypt(session, { iat, uat, exp }); + + const chunkCount = Math.ceil(value.length / this.chunkSize); + if (chunkCount > 1) { + debug('cookie size greater than %d, chunking', this.chunkSize); + for (let i = 0; i < chunkCount; i++) { + const chunkValue = value.slice(i * this.chunkSize, (i + 1) * this.chunkSize); + const chunkCookieName = `${sessionName}.${i}`; + cookieSetter.set(chunkCookieName, chunkValue, cookieOptions); + } + if (sessionName in cookies) { + cookieSetter.clear(sessionName, cookieOptions); + } + } else { + cookieSetter.set(sessionName, value, cookieOptions); + for (const cookieName of Object.keys(cookies)) { + if (cookieName.match(`^${sessionName}\\.\\d$`)) { + cookieSetter.clear(cookieName, cookieOptions); + } + } + } + cookieSetter.commit(res, this.config.session.name); + } + + async deleteSession(req: Req, res: Res, cookieOptions: CookieSerializeOptions): Promise { + const { name: sessionName } = this.config.session; + const cookieSetter = new this.Cookies(); + const cookies = cookieSetter.getAll(req); + + for (const cookieName of Object.keys(cookies)) { + if (cookieName.match(`^${sessionName}(?:\\.\\d)?$`)) { + cookieSetter.clear(cookieName, cookieOptions); + cookieSetter.commit(res, this.config.session.name); + } + } + } +} diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts index e2b62c9e9..43101d93a 100644 --- a/src/auth0-session/transient-store.ts +++ b/src/auth0-session/transient-store.ts @@ -1,7 +1,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { generators } from 'openid-client'; -import * as jose from 'jose'; -import { signing as deriveKey } from './utils/hkdf'; +import { generateCookieValue, getCookieValue } from './utils/signed-cookies'; +import { signing } from './utils/hkdf'; import NodeCookies from './utils/cookies'; import { Config } from './config'; @@ -10,34 +10,6 @@ export interface StoreOptions { value?: string; } -const getCookieValue = async (k: string, v: string, keys: Uint8Array[]): Promise => { - if (!v) { - return undefined; - } - const [value, signature] = v.split('.'); - const flattenedJWS = { - protected: jose.base64url.encode(JSON.stringify({ alg: 'HS256', b64: false, crit: ['b64'] })), - payload: `${k}=${value}`, - signature - }; - for (const key of keys) { - try { - await jose.flattenedVerify(flattenedJWS, key, { - algorithms: ['HS256'] - }); - return value; - } catch (e) {} - } - return; -}; - -export const generateCookieValue = async (cookie: string, value: string, key: Uint8Array): Promise => { - const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) - .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) - .sign(key); - return `${value}.${signature}`; -}; - export default class TransientStore { private keys?: Uint8Array[]; @@ -47,7 +19,7 @@ export default class TransientStore { if (!this.keys) { const secret = this.config.secret; const secrets = Array.isArray(secret) ? secret : [secret]; - this.keys = await Promise.all(secrets.map(deriveKey)); + this.keys = await Promise.all(secrets.map(signing)); } return this.keys; } diff --git a/src/auth0-session/utils/signed-cookies.ts b/src/auth0-session/utils/signed-cookies.ts new file mode 100644 index 000000000..a5c25b6f0 --- /dev/null +++ b/src/auth0-session/utils/signed-cookies.ts @@ -0,0 +1,29 @@ +import * as jose from 'jose'; + +export const getCookieValue = async (k: string, v: string, keys: Uint8Array[]): Promise => { + if (!v) { + return undefined; + } + const [value, signature] = v.split('.'); + const flattenedJWS = { + protected: jose.base64url.encode(JSON.stringify({ alg: 'HS256', b64: false, crit: ['b64'] })), + payload: `${k}=${value}`, + signature + }; + for (const key of keys) { + try { + await jose.flattenedVerify(flattenedJWS, key, { + algorithms: ['HS256'] + }); + return value; + } catch (e) {} + } + return; +}; + +export const generateCookieValue = async (cookie: string, value: string, key: Uint8Array): Promise => { + const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) + .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) + .sign(key); + return `${value}.${signature}`; +}; diff --git a/src/config.ts b/src/config.ts index 49cfb247c..3ad4c8e58 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,8 @@ import { IncomingMessage } from 'http'; import type { AuthorizationParameters as OidcAuthorizationParameters } from 'openid-client'; import type { LoginOptions } from './auth0-session/config'; +import { SessionStore } from './auth0-session/session/stateful-session'; +import Session from './session/session'; import { DeepPartial, get as getBaseConfig } from './auth0-session/get-config'; /** @@ -186,6 +188,20 @@ export interface SessionConfig { */ name: string; + /** + * By default, the session is stateless and stored in an encrypted cookie. But if you want a stateful session + * you can provide a store with `get`, `set` and `destroy` methods to store the session on the server. + */ + store?: SessionStore; + + /** + * A Function for generating a session id when using a custom session store. + * + * **IMPORTANT** If you override this, you must use a suitable value from your platform to + * prevent collisions. For example, for Node: `require('crypto').randomBytes(16).toString('hex')`. + */ + genId?: (req: Req) => string | Promise; + /** * If you want your session duration to be rolling, resetting everytime the * user is active on your site, set this to `true`. If you want the session diff --git a/src/edge.ts b/src/edge.ts index 1faf646b5..8b73b246c 100644 --- a/src/edge.ts +++ b/src/edge.ts @@ -1,5 +1,6 @@ import { NextMiddleware, NextRequest, NextResponse } from 'next/server'; -import { default as CookieStore } from './auth0-session/cookie-store'; +import { StatelessSession } from './auth0-session/session/stateless-session'; +import { StatefulSession } from './auth0-session/session/stateful-session'; import MiddlewareCookies from './utils/middleware-cookies'; import Session from './session/session'; import SessionCache from './session/cache'; @@ -20,6 +21,14 @@ export { WithMiddlewareAuthRequired }; let instance: Auth0Edge; +const genId = () => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +}; + function getInstance(params?: ConfigParameters): Auth0Edge { setIsUsingNamedExports(); if (instance) { @@ -35,11 +44,13 @@ export const initAuth0: InitAuth0 = (params?) => { }; const _initAuth0: InitAuth0 = (params?) => { - const { baseConfig, nextConfig } = getConfig(params); + const { baseConfig, nextConfig } = getConfig({ ...params, session: { genId, ...params?.session } }); // Init base layer (with base config) - const cookieStore = new CookieStore(baseConfig, MiddlewareCookies); - const sessionCache = new SessionCache(baseConfig, cookieStore); + const sessionStore = baseConfig.session.store + ? new StatefulSession(baseConfig, MiddlewareCookies) + : new StatelessSession(baseConfig, MiddlewareCookies); + const sessionCache = new SessionCache(baseConfig, sessionStore); // Init Next layer (with next config) const getSession: GetSession = (req, res) => sessionCache.get(req, res); diff --git a/src/helpers/testing.ts b/src/helpers/testing.ts index 05ce5bf54..72a981fd2 100644 --- a/src/helpers/testing.ts +++ b/src/helpers/testing.ts @@ -1,4 +1,4 @@ -import { Config as BaseConfig, CookieConfig, CookieStore, NodeCookies as Cookies } from '../auth0-session'; +import { Config as BaseConfig, CookieConfig, StatelessSession, NodeCookies as Cookies } from '../auth0-session'; import { Session } from '../session'; /** @@ -27,7 +27,7 @@ export const generateSessionCookie = async ( const weekInSeconds = 7 * 24 * 60 * 60; const { secret, duration: absoluteDuration = weekInSeconds, ...cookie } = config; const cookieStoreConfig = { secret, session: { absoluteDuration, cookie } }; - const cookieStore = new CookieStore(cookieStoreConfig as BaseConfig, Cookies); + const cookieStore = new StatelessSession(cookieStoreConfig as BaseConfig, Cookies); const epoch = (Date.now() / 1000) | 0; return cookieStore.encrypt(session, { iat: epoch, uat: epoch, exp: epoch + absoluteDuration }); }; diff --git a/src/index.ts b/src/index.ts index 9909d5431..4e5c6e5b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ +import crypto from 'crypto'; import { NodeCookies as Cookies, - CookieStore, + StatelessSession, + StatefulSession, + SessionStore as GenericSessionStore, + SessionPayload, TransientStore, clientFactory, loginHandler as baseLoginHandler, @@ -53,6 +57,8 @@ import { import version from './version'; import { getConfig, getLoginUrl, ConfigParameters } from './config'; import { setIsUsingNamedExports, setIsUsingOwnInstance } from './utils/instance-check'; +import { IncomingMessage, ServerResponse } from 'http'; +import { NextApiRequest, NextApiResponse } from 'next'; /** * The SDK server instance. @@ -126,6 +132,8 @@ export type InitAuth0 = (params?: ConfigParameters) => Auth0Server; let instance: Auth0Server & { sessionCache: SessionCache }; +const genId = () => crypto.randomBytes(16).toString('hex'); + // For using managed instance with named exports. function getInstance(): Auth0Server & { sessionCache: SessionCache } { setIsUsingNamedExports(); @@ -144,13 +152,22 @@ export const initAuth0: InitAuth0 = (params) => { }; export const _initAuth = (params?: ConfigParameters): Auth0Server & { sessionCache: SessionCache } => { - const { baseConfig, nextConfig } = getConfig(params); + const { baseConfig, nextConfig } = getConfig({ ...params, session: { genId, ...params?.session } }); // Init base layer (with base config) const getClient = clientFactory(baseConfig, { name: 'nextjs-auth0', version }); const transientStore = new TransientStore(baseConfig); - const cookieStore = new CookieStore(baseConfig, Cookies); - const sessionCache = new SessionCache(baseConfig, cookieStore); + + const sessionStore = baseConfig.session.store + ? new StatefulSession( + baseConfig, + Cookies + ) + : new StatelessSession( + baseConfig, + Cookies + ); + const sessionCache = new SessionCache(baseConfig, sessionStore); const baseHandleLogin = baseLoginHandler(baseConfig, getClient, transientStore); const baseHandleLogout = baseLogoutHandler(baseConfig, getClient, sessionCache); const baseHandleCallback = baseCallbackHandler(baseConfig, getClient, sessionCache, transientStore); @@ -247,4 +264,7 @@ export { GetLoginState, OnError }; + +export type SessionStore = GenericSessionStore; +export type SessionStorePayload = SessionPayload; /* c8 ignore stop */ diff --git a/src/session/cache.ts b/src/session/cache.ts index 291a81d88..bfe4b03d3 100644 --- a/src/session/cache.ts +++ b/src/session/cache.ts @@ -1,7 +1,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { NextApiRequest, NextApiResponse } from 'next'; import type { TokenSet } from 'openid-client'; -import { Config, SessionCache as ISessionCache, CookieStore } from '../auth0-session'; +import { Config, SessionCache as ISessionCache, AbstractSession } from '../auth0-session'; import Session, { fromJson, fromTokenSet } from './session'; export default class SessionCache< @@ -12,14 +12,14 @@ export default class SessionCache< private cache: WeakMap; private iatCache: WeakMap; - constructor(private config: Config, private cookieStore: CookieStore) { + constructor(private config: Config, private sessionStore: AbstractSession) { this.cache = new WeakMap(); this.iatCache = new WeakMap(); } private async init(req: Req, res: Res, autoSave = true): Promise { if (!this.cache.has(req)) { - const [json, iat] = await this.cookieStore.read(req); + const [json, iat] = await this.sessionStore.read(req); this.iatCache.set(req, iat); this.cache.set(req, fromJson(json)); if (this.config.session.rolling && autoSave) { @@ -29,7 +29,7 @@ export default class SessionCache< } async save(req: Req, res: Res): Promise { - await this.cookieStore.save(req, res, this.cache.get(req), this.iatCache.get(req)); + await this.sessionStore.save(req, res, this.cache.get(req), this.iatCache.get(req)); } async create(req: Req, res: Res, session: Session): Promise { diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts index 41af9e3ad..fafd9352e 100644 --- a/tests/auth0-session/fixtures/helpers.ts +++ b/tests/auth0-session/fixtures/helpers.ts @@ -1,6 +1,6 @@ import { Cookie, CookieJar } from 'tough-cookie'; -import { signing as deriveKey } from '../../../src/auth0-session/utils/hkdf'; -import { generateCookieValue } from '../../../src/auth0-session/transient-store'; +import { signing } from '../../../src/auth0-session/utils/hkdf'; +import { generateCookieValue } from '../../../src/auth0-session/utils/signed-cookies'; import { IncomingMessage, request as nodeHttpRequest } from 'http'; import { request as nodeHttpsRequest } from 'https'; import { ConfigParameters } from '../../../src/auth0-session'; @@ -18,7 +18,7 @@ export const defaultConfig: Omit = { export const toSignedCookieJar = async (cookies: { [key: string]: string }, url: string): Promise => { const cookieJar = new CookieJar(); - const signingKey = await deriveKey(secret); + const signingKey = await signing(secret); for (const [key, value] of Object.entries(cookies)) { cookieJar.setCookieSync(`${key}=${await generateCookieValue(key, value, signingKey)}`, url); } diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts index fce41372b..b79e53940 100644 --- a/tests/auth0-session/fixtures/server.ts +++ b/tests/auth0-session/fixtures/server.ts @@ -12,13 +12,15 @@ import { ConfigParameters, clientFactory, TransientStore, - CookieStore, + StatelessSession, SessionCache, logoutHandler, callbackHandler, LoginOptions, LogoutOptions, - CallbackOptions + CallbackOptions, + StatefulSession, + AbstractSession } from '../../../src/auth0-session'; import wellKnown from './well-known.json'; import { jwks } from './cert'; @@ -29,7 +31,7 @@ import version from '../../../src/version'; export type SessionResponse = TokenSetParameters & { claims: Claims }; class TestSessionCache implements SessionCache { - constructor(private cookieStore: CookieStore) {} + constructor(private cookieStore: AbstractSession) {} async create(req: IncomingMessage, res: ServerResponse, tokenSet: TokenSet): Promise { await this.cookieStore.save(req, res, tokenSet); } @@ -60,7 +62,9 @@ const createHandlers = (params: ConfigParameters): Handlers => { const config = getConfig(params); const getClient = clientFactory(config, { name: 'nextjs-auth0', version }); const transientStore = new TransientStore(config); - const cookieStore = new CookieStore(config, Cookies); + const cookieStore = params.session?.store + ? new StatefulSession(config, Cookies) + : new StatelessSession(config, Cookies); const sessionCache = new TestSessionCache(cookieStore); return { diff --git a/tests/auth0-session/handlers/callback.test.ts b/tests/auth0-session/handlers/callback.test.ts index 0baa252e6..3e4631636 100644 --- a/tests/auth0-session/handlers/callback.test.ts +++ b/tests/auth0-session/handlers/callback.test.ts @@ -1,7 +1,7 @@ import nock from 'nock'; import { CookieJar } from 'tough-cookie'; import * as jose from 'jose'; -import { signing as deriveKey } from '../../../src/auth0-session/utils/hkdf'; +import { signing } from '../../../src/auth0-session/utils/hkdf'; import { encodeState } from '../../../src/auth0-session/utils/encoding'; import { SessionResponse, setup, teardown } from '../fixtures/server'; import { makeIdToken } from '../fixtures/cert'; @@ -106,7 +106,7 @@ describe('callback', () => { state: '__valid_state__', id_token: await new jose.SignJWT({ sub: '__test_sub__' }) .setProtectedHeader({ alg: 'HS256' }) - .sign(await deriveKey('secret')) + .sign(await signing('secret')) }, cookieJar }) diff --git a/tests/auth0-session/session/stateful-session.test.ts b/tests/auth0-session/session/stateful-session.test.ts new file mode 100644 index 000000000..dfbf5a208 --- /dev/null +++ b/tests/auth0-session/session/stateful-session.test.ts @@ -0,0 +1,177 @@ +import { TokenSet } from 'openid-client'; +import { setup, teardown } from '../fixtures/server'; +import { defaultConfig, get, post, toSignedCookieJar } from '../fixtures/helpers'; +import { ConfigParameters, SessionStore } from '../../../src/auth0-session'; +import { SessionPayload } from '../../../src/auth0-session/session/abstract-session'; +import { makeIdToken } from '../fixtures/cert'; +import { CookieJar } from 'tough-cookie'; +import { encodeState } from '../../../src/auth0-session/utils/encoding'; + +const hr = 60 * 60 * 1000; +const day = 24 * hr; +const epochNow = (Date.now() / 1000) | 0; + +const login = async (baseURL: string, existingSession?: { appSession: string }): Promise => { + const nonce = '__test_nonce__'; + const state = encodeState({ returnTo: 'https://example.org' }); + const cookieJar = await toSignedCookieJar({ state, nonce, ...existingSession }, baseURL); + await post(baseURL, '/callback', { + body: { + state, + id_token: await makeIdToken({ nonce }) + }, + cookieJar + }); + return cookieJar; +}; + +class Store { + public store: { [key: string]: any }; + constructor() { + this.store = {}; + } + get(id: string) { + return Promise.resolve(this.store[id]); + } + async set(id: string, val: any) { + this.store[id] = val; + await Promise.resolve(); + } + async delete(id: string) { + delete this.store[id]; + await Promise.resolve(); + } +} + +const getPayload = async ( + data = { sub: 'dave' }, + iat = epochNow, + uat = epochNow, + exp = epochNow + day +): Promise> => ({ + header: { iat, uat, exp }, + data: { id_token: await makeIdToken(data) } +}); + +describe('StatefulSession', () => { + let store: SessionStore & { store: { [key: string]: any } }; + let config: ConfigParameters; + let count: number; + + beforeEach(async () => { + store = new Store(); + count = 0; + config = { ...defaultConfig, session: { store, genId: () => count } }; + }); + + afterEach(teardown); + + it('should not create a session when there are no cookies', async () => { + const baseURL = await setup(config); + await expect(get(baseURL, '/session')).rejects.toThrowError('Unauthorized'); + expect(store.store).toEqual({}); + }); + + it('should get an existing session', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup(config); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const session = await get(baseURL, '/session', { cookieJar }); + expect(session).toMatchObject({ + id_token: expect.any(String), + claims: { + nickname: '__test_nickname__', + sub: 'dave', + iss: 'https://op.example.com/', + aud: '__test_client_id__', + iat: expect.any(Number), + exp: expect.any(Number), + nonce: '__test_nonce__' + } + }); + }); + + it('should create a new session', async () => { + const baseURL = await setup(config); + expect(Object.values(store.store)).toHaveLength(0); + const cookieJar = await login(baseURL); + const sessions = Object.values(store.store); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + header: { iat: expect.any(Number), uat: expect.any(Number), exp: expect.any(Number) }, + data: expect.any(TokenSet) + }); + const session = await get(baseURL, '/session', { cookieJar }); + expect(session).toMatchObject({ + id_token: expect.any(String), + claims: { + sub: '__test_sub__' + } + }); + }); + + it('should delete an existing session', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup(config); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const session = await get(baseURL, '/session', { cookieJar }); + expect(session).toMatchObject({ + id_token: expect.any(String), + claims: { + sub: 'dave' + } + }); + expect(Object.values(store.store)).toHaveLength(1); + expect(cookieJar.getCookieStringSync(baseURL)).toMatch(/^appSession=.+/); + await get(baseURL, '/logout', { cookieJar }); + expect(Object.values(store.store)).toHaveLength(0); + expect(cookieJar.getCookieStringSync(baseURL)).toEqual(''); + }); + + it('uses custom session id generator when provided', async () => { + const baseURL = await setup({ ...config, session: { ...config.session, genId: () => 'foobar' } }); + expect(Object.values(store.store)).toHaveLength(0); + const cookieJar = await login(baseURL); + const sessions = Object.values(store.store); + expect(sessions).toHaveLength(1); + expect(cookieJar.getCookieStringSync(baseURL)).toMatch(/^appSession=foobar\..+/); + }); + + it('should regenerate the session when a new user is logging in over an existing user', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup(config); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const session = await get(baseURL, '/session', { cookieJar }); + const sessionIds = Object.keys(store.store); + expect(sessionIds).toHaveLength(1); + expect(session).toMatchObject({ + id_token: expect.any(String), + claims: { + sub: 'dave' + } + }); + expect(store.store).toHaveProperty('foo'); + await login(baseURL, { appSession: 'foo' }); + expect(store.store).not.toHaveProperty('foo'); + const newSessionIds = Object.keys(store.store); + expect(newSessionIds).toHaveLength(1); + const [oldSessionId] = sessionIds; + const [newSessionId] = newSessionIds; + expect(oldSessionId).toBe('foo'); + expect(newSessionId).not.toBe('foo'); + expect(newSessionId).toBeTruthy(); + }); + + it('should rotate signing secrets', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup({ ...config, secret: ['__test_session_secret__', '__old_session_secret__'] }); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const session = await get(baseURL, '/session', { cookieJar }); + expect(session).toMatchObject({ + id_token: expect.any(String), + claims: { + sub: 'dave' + } + }); + }); +}); diff --git a/tests/auth0-session/cookie-store.test.ts b/tests/auth0-session/session/stateless-session.test.ts similarity index 98% rename from tests/auth0-session/cookie-store.test.ts rename to tests/auth0-session/session/stateless-session.test.ts index 03020ba7d..16b21fd6a 100644 --- a/tests/auth0-session/cookie-store.test.ts +++ b/tests/auth0-session/session/stateless-session.test.ts @@ -1,16 +1,16 @@ import { randomBytes } from 'crypto'; import * as jose from 'jose'; import { IdTokenClaims } from 'openid-client'; -import { setup, teardown } from './fixtures/server'; -import { defaultConfig, fromCookieJar, get, toCookieJar } from './fixtures/helpers'; -import { encryption as deriveKey } from '../../src/auth0-session/utils/hkdf'; -import { makeIdToken } from './fixtures/cert'; +import { setup, teardown } from '../fixtures/server'; +import { defaultConfig, fromCookieJar, get, toCookieJar } from '../fixtures/helpers'; +import { encryption } from '../../../src/auth0-session/utils/hkdf'; +import { makeIdToken } from '../fixtures/cert'; const hr = 60 * 60 * 1000; const day = 24 * hr; const encrypted = async (claims: Partial = { sub: '__test_sub__' }): Promise => { - const key = await deriveKey(defaultConfig.secret as string); + const key = await encryption(defaultConfig.secret as string); const epochNow = (Date.now() / 1000) | 0; const weekInSeconds = 7 * 24 * 60 * 60; const payload = { @@ -31,7 +31,7 @@ const encrypted = async (claims: Partial = { sub: '__test_sub__' .encrypt(key); }; -describe('CookieStore', () => { +describe('StatelessSession', () => { afterEach(teardown); it('should not create a session when there are no cookies', async () => { diff --git a/tests/auth0-session/transient-store.test.ts b/tests/auth0-session/transient-store.test.ts index 7f33dcb3b..a50162781 100644 --- a/tests/auth0-session/transient-store.test.ts +++ b/tests/auth0-session/transient-store.test.ts @@ -2,12 +2,12 @@ import { IncomingMessage, ServerResponse } from 'http'; import * as jose from 'jose'; import { CookieJar } from 'tough-cookie'; import { getConfig, TransientStore } from '../../src/auth0-session/'; -import { signing as deriveKey } from '../../src/auth0-session/utils/hkdf'; +import { signing } from '../../src/auth0-session/utils/hkdf'; import { defaultConfig, fromCookieJar, get, getCookie, toSignedCookieJar } from './fixtures/helpers'; import { setup as createServer, teardown } from './fixtures/server'; const generateSignature = async (cookie: string, value: string): Promise => { - const key = await deriveKey(defaultConfig.secret as string); + const key = await signing(defaultConfig.secret as string); const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) .sign(key); diff --git a/tests/handlers/callback.test.ts b/tests/handlers/callback.test.ts index 79be303d2..0ef24f11b 100644 --- a/tests/handlers/callback.test.ts +++ b/tests/handlers/callback.test.ts @@ -8,7 +8,7 @@ import { encodeState } from '../../src/auth0-session/utils/encoding'; import { defaultOnError, setup, teardown } from '../fixtures/setup'; import { Session, AfterCallback, MissingStateCookieError } from '../../src'; import nock from 'nock'; -import { signing as deriveKey } from '../../src/auth0-session/utils/hkdf'; +import { signing } from '../../src/auth0-session/utils/hkdf'; const callback = (baseUrl: string, body: any, cookieJar?: CookieJar): Promise => post(baseUrl, `/api/auth/callback`, { @@ -18,7 +18,7 @@ const callback = (baseUrl: string, body: any, cookieJar?: CookieJar): Promise => { - const key = await deriveKey(defaultConfig.secret as string); + const key = await signing(defaultConfig.secret as string); const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) .sign(key); diff --git a/tests/helpers/testing.test.ts b/tests/helpers/testing.test.ts index aecd41035..0fb45ddfd 100644 --- a/tests/helpers/testing.test.ts +++ b/tests/helpers/testing.test.ts @@ -1,7 +1,7 @@ -import CookieStore from '../../src/auth0-session/cookie-store'; +import { StatelessSession as CookieStore } from '../../src/auth0-session/session/stateless-session'; import { generateSessionCookie } from '../../src/helpers/testing'; -jest.mock('../../src/auth0-session/cookie-store'); +jest.mock('../../src/auth0-session/session/stateless-session'); const encryptMock = jest.spyOn(CookieStore.prototype, 'encrypt'); const weekInSeconds = 7 * 24 * 60 * 60; diff --git a/tests/helpers/with-middleware-auth-required.test.ts b/tests/helpers/with-middleware-auth-required.test.ts index 51e801ab6..78f4a7691 100644 --- a/tests/helpers/with-middleware-auth-required.test.ts +++ b/tests/helpers/with-middleware-auth-required.test.ts @@ -6,13 +6,13 @@ import { NextFetchEvent } from 'next/dist/server/web/spec-extension/fetch-event' import { initAuth0 } from '../../src/edge'; import { withoutApi } from '../fixtures/default-settings'; import { IdTokenClaims } from 'openid-client'; -import { encryption as deriveKey } from '../../src/auth0-session/utils/hkdf'; +import { encryption } from '../../src/auth0-session/utils/hkdf'; import { defaultConfig } from '../auth0-session/fixtures/helpers'; import { makeIdToken } from '../auth0-session/fixtures/cert'; import * as jose from 'jose'; const encrypted = async (claims: Partial = { sub: '__test_sub__' }): Promise => { - const key = await deriveKey(defaultConfig.secret as string); + const key = await encryption(defaultConfig.secret as string); const epochNow = (Date.now() / 1000) | 0; const weekInSeconds = 7 * 24 * 60 * 60; const payload = { diff --git a/tests/session/cache.test.ts b/tests/session/cache.test.ts index acf05b425..e5d7437c1 100644 --- a/tests/session/cache.test.ts +++ b/tests/session/cache.test.ts @@ -1,7 +1,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { Socket } from 'net'; import { mocked } from 'ts-jest/utils'; -import { NodeCookies as Cookies, CookieStore, getConfig } from '../../src/auth0-session'; +import { NodeCookies as Cookies, StatelessSession, getConfig } from '../../src/auth0-session'; import { ConfigParameters, Session, SessionCache } from '../../src'; import { withoutApi } from '../fixtures/default-settings'; @@ -10,15 +10,15 @@ describe('SessionCache', () => { let req: IncomingMessage; let res: ServerResponse; let session: Session; - let cookieStore: CookieStore; + let sessionStore: StatelessSession; const setup = (conf: ConfigParameters) => { const config = getConfig(conf); - cookieStore = mocked(new CookieStore(config, Cookies)); - cookieStore.save = jest.fn(); + sessionStore = mocked(new StatelessSession(config, Cookies)); + sessionStore.save = jest.fn(); session = new Session({ sub: '__test_user__' }); session.idToken = '__test_id_token__'; - cache = new SessionCache(config, cookieStore); + cache = new SessionCache(config, sessionStore); req = mocked(new IncomingMessage(new Socket())); res = mocked(new ServerResponse(req)); }; @@ -34,7 +34,7 @@ describe('SessionCache', () => { test('should create the session entry', async () => { await cache.create(req, res, session); expect(await cache.get(req, res)).toEqual(session); - expect(cookieStore.save).toHaveBeenCalledWith(req, res, session, undefined); + expect(sessionStore.save).toHaveBeenCalledWith(req, res, session, undefined); }); test('should delete the session entry', async () => { @@ -63,23 +63,23 @@ describe('SessionCache', () => { }); test('should save the session on read and update with a rolling session', async () => { - cookieStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); + sessionStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); expect(await cache.isAuthenticated(req, res)).toEqual(true); expect((await cache.get(req, res))?.user).toEqual({ sub: '__test_user__' }); await cache.set(req, res, new Session({ sub: '__new_user__' })); expect((await cache.get(req, res))?.user).toEqual({ sub: '__new_user__' }); - expect(cookieStore.read).toHaveBeenCalledTimes(1); - expect(cookieStore.save).toHaveBeenCalledTimes(2); + expect(sessionStore.read).toHaveBeenCalledTimes(1); + expect(sessionStore.save).toHaveBeenCalledTimes(2); }); test('should save the session only on update without a rolling session', async () => { setup({ ...withoutApi, session: { rolling: false } }); - cookieStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); + sessionStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); expect(await cache.isAuthenticated(req, res)).toEqual(true); expect((await cache.get(req, res))?.user).toEqual({ sub: '__test_user__' }); cache.set(req, res, new Session({ sub: '__new_user__' })); expect((await cache.get(req, res))?.user).toEqual({ sub: '__new_user__' }); - expect(cookieStore.read).toHaveBeenCalledTimes(1); - expect(cookieStore.save).toHaveBeenCalledTimes(1); + expect(sessionStore.read).toHaveBeenCalledTimes(1); + expect(sessionStore.save).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/stateful-session.test.ts b/tests/stateful-session.test.ts new file mode 100644 index 000000000..dd5eafc70 --- /dev/null +++ b/tests/stateful-session.test.ts @@ -0,0 +1,101 @@ +import { withoutApi } from './fixtures/default-settings'; +import { get, toSignedCookieJar } from './auth0-session/fixtures/helpers'; +import { setup, teardown, login } from './fixtures/setup'; +import { SessionPayload } from '../src/auth0-session/session/abstract-session'; +import { makeIdToken } from './auth0-session/fixtures/cert'; +import { ConfigParameters, SessionStore } from '../src/auth0-session'; +import { TokenSet } from 'openid-client'; + +const hr = 60 * 60 * 1000; +const day = 24 * hr; +const epochNow = (Date.now() / 1000) | 0; + +class Store { + public store: { [key: string]: any }; + constructor() { + this.store = {}; + } + get(id: string) { + return Promise.resolve(this.store[id]); + } + async set(id: string, val: any) { + this.store[id] = val; + await Promise.resolve(); + } + async delete(id: string) { + delete this.store[id]; + await Promise.resolve(); + } +} + +const getPayload = async ( + data = { sub: 'dave' }, + iat = epochNow, + uat = epochNow, + exp = epochNow + day +): Promise> => ({ + header: { iat, uat, exp }, + data: { id_token: await makeIdToken(data), user: data } +}); + +describe('next stateful session', () => { + let store: SessionStore & { store: { [key: string]: any } }; + let config: ConfigParameters; + + beforeEach(async () => { + store = new Store(); + config = { ...withoutApi, session: { store } }; + }); + + afterEach(teardown); + + it('should not create a session when there are no cookies', async () => { + const baseURL = await setup(config); + await expect(get(baseURL, '/api/auth/me')).resolves.toBe(''); + expect(store.store).toEqual({}); + }); + + test('should create a new session', async () => { + const baseUrl = await setup(config); + const cookieJar = await login(baseUrl); + + const profile = await get(baseUrl, '/api/auth/me', { cookieJar }); + expect(profile).toStrictEqual({ nickname: '__test_nickname__', sub: '__test_sub__' }); + expect(Object.keys(store)).toHaveLength(1); + }); + + it('should get an existing session', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup(config); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const profile = await get(baseURL, '/api/auth/me', { cookieJar }); + expect(profile).toMatchObject({ + sub: 'dave' + }); + }); + + it('should delete an existing session', async () => { + await store.set('foo', await getPayload()); + const baseURL = await setup(config); + const cookieJar = await toSignedCookieJar({ appSession: 'foo' }, baseURL); + const profile = await get(baseURL, '/api/auth/me', { cookieJar }); + expect(profile).toMatchObject({ + sub: 'dave' + }); + expect(Object.values(store.store)).toHaveLength(1); + expect(cookieJar.getCookieStringSync(baseURL)).toMatch(/^appSession=foo\..+/); + await get(baseURL, '/api/auth/logout', { cookieJar }); + expect(Object.values(store.store)).toHaveLength(0); + expect(cookieJar.getCookieStringSync(baseURL)).toEqual(''); + }); + + it('uses custom session id generator when provided', async () => { + const baseUrl = await setup({ ...config, session: { ...config.session, genId: () => 'foo' } }); + const cookieJar = await login(baseUrl); + + const profile = await get(baseUrl, '/api/auth/me', { cookieJar }); + expect(profile).toStrictEqual({ nickname: '__test_nickname__', sub: '__test_sub__' }); + expect(Object.keys(store)).toHaveLength(1); + expect(cookieJar.getCookieStringSync(baseUrl)).toMatch(/^appSession=foo\..+/); + }); +});