From 76329eaf3112b84bd04c077978016b70379779ed Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Wed, 15 Apr 2026 19:02:43 +0600 Subject: [PATCH] feat(backend): use stateless OAuth2 state store (#6098) --- packages/hoppscotch-backend/package.json | 1 - .../src/auth/stateless-state-store.ts | 265 ++++++++++++++++++ .../src/auth/strategies/github.strategy.ts | 9 +- .../src/auth/strategies/google.strategy.ts | 9 +- .../src/auth/strategies/microsoft.strategy.ts | 9 +- packages/hoppscotch-backend/src/main.ts | 17 -- pnpm-lock.yaml | 39 --- 7 files changed, 289 insertions(+), 60 deletions(-) create mode 100644 packages/hoppscotch-backend/src/auth/stateless-state-store.ts diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index 6bcb8bf0..91e46822 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -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", diff --git a/packages/hoppscotch-backend/src/auth/stateless-state-store.ts b/packages/hoppscotch-backend/src/auth/stateless-state-store.ts new file mode 100644 index 00000000..785f8be7 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/stateless-state-store.ts @@ -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'); + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts index 7c085d10..17de3361 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts @@ -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('INFRA.GITHUB_CLIENT_SECRET'), callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'), scope: [configService.get('INFRA.GITHUB_SCOPE')], - store: true, + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_github', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), }); } diff --git a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts index 523392c0..7b5e1153 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts @@ -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('INFRA.GOOGLE_CALLBACK_URL'), scope: configService.get('INFRA.GOOGLE_SCOPE').split(','), passReqToCallback: true, - store: true, + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_google', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), }); } diff --git a/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts index 885542df..262630e8 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts @@ -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('INFRA.MICROSOFT_CALLBACK_URL'), scope: configService.get('INFRA.MICROSOFT_SCOPE').split(','), tenant: configService.get('INFRA.MICROSOFT_TENANT'), - store: true, + store: new StatelessStateStore( + configService.get('INFRA.SESSION_SECRET'), + undefined, + (configService.get('INFRA.SESSION_COOKIE_NAME') || + '__oauth_nonce') + '_microsoft', + configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true', + ), }); } diff --git a/packages/hoppscotch-backend/src/main.ts b/packages/hoppscotch-backend/src/main.ts index 47f5e248..94be62be 100644 --- a/packages/hoppscotch-backend/src/main.ts +++ b/packages/hoppscotch-backend/src/main.ts @@ -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('INFRA.SESSION_COOKIE_NAME') || 'connect.sid', - secret: - configService.get('INFRA.SESSION_SECRET') || - crypto.randomBytes(16).toString('hex'), - resave: false, - saveUninitialized: false, - }), - ); - // Increase file upload limit to 100MB app.use( json({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e9ce1b6..c3519f0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: