feat(backend): use stateless OAuth2 state store (#6098)

This commit is contained in:
Mir Arif Hasan 2026-04-15 19:02:43 +06:00 committed by GitHub
parent 5d855f95c9
commit 76329eaf31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 289 additions and 60 deletions

View file

@ -56,7 +56,6 @@
"cookie-parser": "1.4.7",
"dotenv": "17.3.1",
"express": "5.2.1",
"express-session": "1.19.0",
"fp-ts": "2.16.11",
"graphql": "16.13.1",
"graphql-query-complexity": "1.1.0",

View file

@ -0,0 +1,265 @@
import * as crypto from 'crypto';
/**
* Module-level fallback secret for single-instance deployments
* where INFRA.SESSION_SECRET is not configured.
*/
const FALLBACK_SECRET = crypto.randomBytes(32).toString('hex');
/**
* Maximum serialized payload size in bytes.
* Prevents oversized state parameters that could break provider redirects
* or exceed URL length limits.
*/
const MAX_PAYLOAD_BYTES = 2048;
/**
* Default cookie name used to bind the OAuth state to a specific browser session.
* Prevents login CSRF by ensuring the callback can only be completed
* by the same browser that initiated the OAuth flow.
*/
const DEFAULT_COOKIE_NAME = '__oauth_nonce';
/**
* A stateless OAuth state store for passport strategies.
*
* Instead of storing state in express-session (MemoryStore), this encodes
* the state as a signed, time-limited token passed through the OAuth `state`
* query parameter. The token format is: `base64url(payload).base64url(hmac)`.
*
* CSRF protection: A random nonce is generated per OAuth flow, stored in a
* SameSite=Lax HttpOnly cookie, and mixed into the HMAC signature. On
* callback, the nonce from the cookie is used to verify the signature,
* ensuring the same browser that started the flow completes it.
*
* This eliminates all server-side session state from the OAuth flow, making
* it fully compatible with horizontal scaling (multiple backend instances
* behind a load balancer).
*
* Supports both passport-oauth2 (Google, GitHub, Microsoft) which dispatches
* by `.length`, and passport-openidconnect (OIDC) which always calls with 5 args.
*
* IMPORTANT: For multi-instance deployments, `INFRA.SESSION_SECRET` must be
* set to the same value across all instances.
*/
export class StatelessStateStore {
private readonly secret: string;
private readonly maxAgeMs: number;
private readonly cookieName: string;
private readonly secureCookies: boolean;
constructor(
secret?: string,
maxAgeMs: number = 600_000,
cookieName?: string,
secureCookies?: boolean,
) {
if (!secret) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'StatelessStateStore: INFRA.SESSION_SECRET must be set in production. Without a shared secret, OAuth callbacks will fail across load-balanced instances.',
);
}
console.warn(
'[StatelessStateStore] INFRA.SESSION_SECRET not set; using ephemeral fallback. OAuth will fail in any multi-instance deployment.',
);
}
this.secret = secret || FALLBACK_SECRET;
this.maxAgeMs = maxAgeMs;
this.cookieName = cookieName || DEFAULT_COOKIE_NAME;
this.secureCookies = secureCookies ?? false;
}
/**
* Called by passport before redirecting to the OAuth provider.
*
* passport-oauth2 dispatches by `.length`:
* arity 5: store(req, verifier, state, meta, cb) PKCE flow
* arity 4: store(req, state, meta, cb) standard flow
* arity 3: store(req, meta, cb) no state
*
* passport-openidconnect always calls with 5 args:
* store(req, ctx, appState, meta, cb)
*
* We declare 5 params so `.length === 5` satisfies both libraries.
*
* The token payload stores both `ctx` (OIDC context / PKCE verifier) and
* `state` (the app state, e.g. { redirect_uri }) so that verify() can
* return them both correctly to each library's restore/loaded callback.
*/
store(
req: any,
ctxOrState: any,
stateOrMeta: any,
metaOrCb: any,
cb?: Function,
): void {
let ctx: any;
let state: any;
let callback: Function;
if (typeof cb === 'function') {
// 5-arg: (req, verifier/ctx, state, meta, cb)
// For passport-oauth2 PKCE: verifier is a string, state is the app state
// For passport-openidconnect: ctx has { maxAge, nonce, issued }, state is app state
ctx = ctxOrState;
state = stateOrMeta;
callback = cb;
} else if (typeof metaOrCb === 'function') {
// 4-arg: (req, state, meta, cb)
ctx = undefined;
state = ctxOrState;
callback = metaOrCb;
} else if (typeof stateOrMeta === 'function') {
// 3-arg: (req, meta, cb)
ctx = undefined;
state = {};
callback = stateOrMeta;
} else {
throw new Error('StatelessStateStore.store: invalid arguments');
}
try {
// Generate a browser-bound nonce for CSRF protection
const nonce = crypto.randomBytes(16).toString('hex');
const payload: any = {
state: state,
exp: Date.now() + this.maxAgeMs,
nonce: nonce,
};
// Store ctx only if defined (OIDC context or PKCE verifier)
if (ctx !== undefined && ctx !== null) {
payload.ctx = ctx;
}
const serialized = JSON.stringify(payload);
if (Buffer.byteLength(serialized) > MAX_PAYLOAD_BYTES) {
return callback(
new Error('OAuth state payload exceeds maximum allowed size'),
);
}
const encoded = this.encode(payload);
// Set the nonce as a SameSite cookie to bind the OAuth flow to this browser
if (req.res) {
req.res.cookie(this.cookieName, nonce, {
httpOnly: true,
sameSite: 'lax',
secure: this.secureCookies,
maxAge: this.maxAgeMs,
path: '/',
});
}
callback(null, encoded);
} catch (err) {
callback(err);
}
}
/**
* Called by passport on the callback to verify the state parameter.
*
* passport-oauth2 dispatches by `.length`:
* arity 4: verify(req, state, meta, cb)
* arity 3: verify(req, state, cb)
*
* passport-openidconnect always calls with 3 args:
* verify(req, handle, cb) restored(err, ctx, state)
*
* We declare 3 params (matching both) since passport-oauth2 will dispatch
* to arity 3 when `.length === 3`.
*
* Return semantics (second arg third arg of cb):
* passport-oauth2: cb(null, ok:truthy|string, appState)
* passport-openidconnect: cb(null, ctx:{maxAge,nonce,issued}, appState)
*
* We return the stored ctx (or `true` when ctx was not stored) as the
* second arg, and the app state as the third arg. This satisfies both:
* - passport-oauth2 just needs truthy (or a PKCE verifier string)
* - passport-openidconnect needs the OIDC context object
*/
verify(req: any, providedState: string, cb: Function): void {
try {
// Read the browser-bound nonce from the cookie
const cookieNonce = req.cookies?.[this.cookieName];
// Clear the nonce cookie regardless of outcome
if (req.res) {
req.res.clearCookie(this.cookieName, { path: '/' });
}
if (!cookieNonce) {
return cb(null, false, { message: 'Missing OAuth nonce cookie' });
}
const payload = this.decode(providedState, cookieNonce);
if (!payload) {
return cb(null, false, { message: 'Invalid state signature' });
}
if (payload.exp && Date.now() > payload.exp) {
return cb(null, false, { message: 'State has expired' });
}
// Return ctx (OIDC context or PKCE verifier) as second arg,
// falling back to `true` for plain OAuth2 flows without ctx.
// Return the app state (e.g. { redirect_uri }) as third arg.
const ctx = payload.ctx !== undefined ? payload.ctx : true;
cb(null, ctx, payload.state);
} catch (err) {
cb(err);
}
}
/**
* Decode and verify a signed state token.
* Returns null if the token is missing, malformed, or the signature is invalid.
*/
private decode(token: string, nonce?: string): any | null {
try {
if (!token) return null;
const dotIndex = token.indexOf('.');
if (dotIndex === -1) return null;
const payloadStr = token.substring(0, dotIndex);
const signature = token.substring(dotIndex + 1);
const expectedSignature = this.sign(payloadStr, nonce);
if (
signature.length !== expectedSignature.length ||
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
return JSON.parse(Buffer.from(payloadStr, 'base64url').toString());
} catch {
return null;
}
}
private encode(payload: any): string {
const payloadStr = Buffer.from(JSON.stringify(payload)).toString(
'base64url',
);
const signature = this.sign(payloadStr, payload.nonce);
return `${payloadStr}.${signature}`;
}
private sign(data: string, nonce?: string): string {
const hmac = crypto.createHmac('sha256', this.secret);
hmac.update(data);
if (nonce) {
hmac.update(nonce);
}
return hmac.digest('base64url');
}
}

View file

@ -8,6 +8,7 @@ import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { validateEmail } from 'src/utils';
import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors';
import { StatelessStateStore } from '../stateless-state-store';
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) {
@ -21,7 +22,13 @@ export class GithubStrategy extends PassportStrategy(Strategy) {
clientSecret: configService.get<string>('INFRA.GITHUB_CLIENT_SECRET'),
callbackURL: configService.get<string>('INFRA.GITHUB_CALLBACK_URL'),
scope: [configService.get<string>('INFRA.GITHUB_SCOPE')],
store: true,
store: new StatelessStateStore(
configService.get<string>('INFRA.SESSION_SECRET'),
undefined,
(configService.get<string>('INFRA.SESSION_COOKIE_NAME') ||
'__oauth_nonce') + '_github',
configService.get<string>('INFRA.ALLOW_SECURE_COOKIES') === 'true',
),
});
}

View file

@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { validateEmail } from 'src/utils';
import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors';
import { StatelessStateStore } from '../stateless-state-store';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
@ -23,7 +24,13 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
callbackURL: configService.get<string>('INFRA.GOOGLE_CALLBACK_URL'),
scope: configService.get<string>('INFRA.GOOGLE_SCOPE').split(','),
passReqToCallback: true,
store: true,
store: new StatelessStateStore(
configService.get<string>('INFRA.SESSION_SECRET'),
undefined,
(configService.get<string>('INFRA.SESSION_COOKIE_NAME') ||
'__oauth_nonce') + '_google',
configService.get<string>('INFRA.ALLOW_SECURE_COOKIES') === 'true',
),
});
}

View file

@ -8,6 +8,7 @@ import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { validateEmail } from 'src/utils';
import { AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH } from 'src/errors';
import { StatelessStateStore } from '../stateless-state-store';
@Injectable()
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
@ -22,7 +23,13 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
callbackURL: configService.get<string>('INFRA.MICROSOFT_CALLBACK_URL'),
scope: configService.get<string>('INFRA.MICROSOFT_SCOPE').split(','),
tenant: configService.get<string>('INFRA.MICROSOFT_TENANT'),
store: true,
store: new StatelessStateStore(
configService.get<string>('INFRA.SESSION_SECRET'),
undefined,
(configService.get<string>('INFRA.SESSION_COOKIE_NAME') ||
'__oauth_nonce') + '_microsoft',
configService.get<string>('INFRA.ALLOW_SECURE_COOKIES') === 'true',
),
});
}

View file

@ -3,9 +3,7 @@ import { json } from 'express';
import { AppModule } from './app.module';
import cookieParser from 'cookie-parser';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema';
import * as crypto from 'crypto';
import morgan from 'morgan';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@ -50,21 +48,6 @@ async function bootstrap() {
console.log(`Running in production: ${isProduction}`);
console.log(`Port: ${configService.get('PORT')}`);
app.use(
session({
// Allow overriding the default cookie name 'connect.sid' (which contains a dot).
// Some proxies/load balancers (like older Kong versions) cannot hash cookie names with dots,
// so we allow setting an alternative name via the INFRA.SESSION_COOKIE_NAME configuration.
name:
configService.get<string>('INFRA.SESSION_COOKIE_NAME') || 'connect.sid',
secret:
configService.get<string>('INFRA.SESSION_SECRET') ||
crypto.randomBytes(16).toString('hex'),
resave: false,
saveUninitialized: false,
}),
);
// Increase file upload limit to 100MB
app.use(
json({

View file

@ -258,9 +258,6 @@ importers:
express:
specifier: 5.2.1
version: 5.2.1
express-session:
specifier: 1.19.0
version: 1.19.0
fp-ts:
specifier: 2.16.11
version: 2.16.11
@ -6818,9 +6815,6 @@ packages:
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@ -7761,10 +7755,6 @@ packages:
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
express-session@1.19.0:
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
engines: {node: '>= 0.8.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@ -10716,10 +10706,6 @@ packages:
ramda@0.27.2:
resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==}
random-bytes@1.0.0:
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
engines: {node: '>= 0.8'}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@ -11838,10 +11824,6 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
uid-safe@2.1.5:
resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
engines: {node: '>= 0.8'}
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
@ -18833,8 +18815,6 @@ snapshots:
cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@ -19938,19 +19918,6 @@ snapshots:
jest-mock: 30.3.0
jest-util: 30.3.0
express-session@1.19.0:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
on-headers: 1.1.0
parseurl: 1.3.3
safe-buffer: 5.2.1
uid-safe: 2.1.5
transitivePeerDependencies:
- supports-color
express@5.2.1:
dependencies:
accepts: 2.0.0
@ -23647,8 +23614,6 @@ snapshots:
ramda@0.27.2: {}
random-bytes@1.0.0: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@ -25016,10 +24981,6 @@ snapshots:
uglify-js@3.19.3:
optional: true
uid-safe@2.1.5:
dependencies:
random-bytes: 1.0.0
uid2@0.0.4: {}
uid@2.0.2: