From 0462ddd6f58ce6d544e6749ecffc6d3a4e928779 Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:58:49 -0400 Subject: [PATCH] add support for step-up sessions (#3395) * add support for step-up sessions * update step up session --- package-lock.json | 23 +- .../controllers/auth/AuthController.test.ts | 133 +++++++++++ .../controllers/auth/AuthController.ts | 129 +++++++++++ src/backend/core/http/index.ts | 10 + .../http/middleware/stepUpSession.test.ts | 209 ++++++++++++++++++ .../core/http/middleware/stepUpSession.ts | 171 ++++++++++++++ src/backend/server.ts | 7 + src/puter-js/tests/api/suites/auth.suite.ts | 32 ++- 8 files changed, 691 insertions(+), 23 deletions(-) create mode 100644 src/backend/core/http/middleware/stepUpSession.test.ts create mode 100644 src/backend/core/http/middleware/stepUpSession.ts diff --git a/package-lock.json b/package-lock.json index f389e2728..a786918cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "dedent": "^1.5.3", "javascript-time-ago": "^2.5.11", "libphonenumber-js": "1.13.6", + "miniflare": "^4.20260617.1", "open": "^10.1.0" }, "devDependencies": { @@ -1094,7 +1095,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1111,7 +1111,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1128,7 +1127,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1145,7 +1143,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1162,7 +1159,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1176,7 +1172,6 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -1189,7 +1184,6 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -3023,7 +3017,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3044,7 +3037,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -5432,7 +5424,6 @@ "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, "license": "MIT", "dependencies": { "kleur": "^4.1.5" @@ -5442,7 +5433,6 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, "license": "MIT", "dependencies": { "@poppinss/colors": "^4.1.5", @@ -5454,7 +5444,6 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5467,7 +5456,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, "license": "MIT" }, "node_modules/@prelude.so/core": { @@ -5860,7 +5848,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6127,7 +6114,6 @@ "version": "1.2.17", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, "license": "CC0-1.0" }, "node_modules/@stablelib/base64": { @@ -9532,7 +9518,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -12432,7 +12417,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13473,7 +13457,6 @@ "version": "4.20260701.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz", "integrity": "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==", - "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", @@ -17849,7 +17832,6 @@ "version": "1.20260701.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260701.1.tgz", "integrity": "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -18084,7 +18066,6 @@ "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, "license": "MIT", "dependencies": { "@poppinss/colors": "^4.1.5", @@ -18098,7 +18079,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, "license": "MIT", "dependencies": { "@poppinss/exception": "^1.2.2", @@ -18109,7 +18089,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index fa26a180e..5473a8e31 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1154,6 +1154,139 @@ describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => { }); }); +// ── Step-up (elevation) ───────────────────────────────────────────── + +describe('AuthController.handleElevate', () => { + it('password account: correct password mints the elevation cookie', async () => { + const { actor } = await makeUserAndActor(); + const res = makeRes(); + await controller.handleElevate( + makeReq({ password: 'correct-horse-battery' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ elevated: true }); + expect(res.cookies.puter_elevated).toBeDefined(); + expect(res.cookies.puter_elevated.opts).toMatchObject({ httpOnly: true }); + + // The minted cookie satisfies verifyStepUpSession for this same user. + const { verifyStepUpSession } = await import( + '../../core/http/middleware/stepUpSession.js' + ); + const ok = verifyStepUpSession( + { + cookies: { puter_elevated: res.cookies.puter_elevated.value }, + actor: { user: { uuid: actor.user.uuid } }, + } as never, + { tokenService: server.services.token }, + ); + expect(ok).toBe(true); + }); + + it('password account: wrong password → 401 password_mismatch', async () => { + const { actor } = await makeUserAndActor(); + await expect( + controller.handleElevate( + makeReq({ password: 'nope' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 401, + legacyCode: 'password_mismatch', + }); + }); + + it('2FA account: a live TOTP code elevates; a wrong code is rejected', async () => { + const { TOTP } = await import('otpauth'); + const { createSecret } = await import( + '../../services/auth/OTPUtil.js' + ); + const { user, actor } = await makeUserAndActor(); + const { secret } = createSecret(user.username); + await server.stores.user.update(user.id, { + otp_enabled: 1, + otp_secret: secret, + }); + // Reflect the enabled state on the actor the way the auth probe would. + const otpActor = { + user: { ...actor.user, otp_enabled: true }, + } as never; + + const totp = new TOTP({ + issuer: 'puter.com', + label: user.username, + algorithm: 'SHA1', + digits: 6, + secret, + }); + + const res = makeRes(); + await controller.handleElevate( + makeReq({ code: totp.generate() }, { actor: otpActor }), + res, + ); + expect(res.body).toMatchObject({ elevated: true }); + expect(res.cookies.puter_elevated).toBeDefined(); + + await expect( + controller.handleElevate( + makeReq({ code: '000000' }, { actor: otpActor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('account with no password and 2FA off cannot elevate → 403', async () => { + const { user, actor } = await makeUserAndActor(); + await server.stores.user.update(user.id, { password: null }); + await expect( + controller.handleElevate( + makeReq({ password: 'anything' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'elevation_unavailable', + }); + }); + + it('API clients (no cookie) get the token back to send as a header', async () => { + const { actor } = await makeUserAndActor(); + const res = makeRes(); + await controller.handleElevate( + makeReq({ password: 'correct-horse-battery' }, { actor }), + res, + ); + expect(typeof (res.body as { token?: string }).token).toBe('string'); + }); + + it('browser sessions (cookie-authed) do NOT get the raw token in the body', async () => { + const { actor } = await makeUserAndActor(); + const res = makeRes(); + // Mimic the browser: the resolved token IS the session cookie value. + // Cookie name must match `config.cookie_name` (puter_auth_token). + const req = { + ...makeReq({ password: 'correct-horse-battery' }, { actor }), + token: 'session-cookie-value', + cookies: { puter_auth_token: 'session-cookie-value' }, + }; + await controller.handleElevate(req, res); + expect(res.body).toEqual({ elevated: true }); + expect(res.cookies.puter_elevated).toBeDefined(); + }); + + it('the elevation token is never honored as a main auth token', async () => { + const { user } = await makeUserAndActor(); + const { signStepUpToken } = await import( + '../../core/http/middleware/stepUpSession.js' + ); + const token = signStepUpToken(server.services.token, { + uuid: user.uuid, + }); + const result = await server.services.auth.authenticate(token); + expect(result.actor).toBeUndefined(); + }); +}); + // ── Logout ────────────────────────────────────────────────────────── describe('AuthController.handleLogout', () => { diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 6df28fe16..75270c96c 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -28,6 +28,11 @@ import { HttpError } from '../../core/http/HttpError.js'; import { antiCsrf } from '../../core/http/middleware/antiCsrf.js'; import { generateCaptcha } from '../../core/http/middleware/captcha.js'; import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; +import { + signStepUpToken, + STEP_UP_COOKIE_NAME, + stepUpCookieOptions, +} from '../../core/http/middleware/stepUpSession.js'; import { createUserProtectedGate, createWebSessionActorGate, @@ -964,6 +969,11 @@ export class AuthController extends PuterController { // a stale value would re-authenticate the next request. res.clearCookie(this.config.cookie_name ?? 'puter_token'); res.clearCookie('puter_token_v2'); + // Drop any step-up elevation too, so it can't reactivate on a shared + // machine. + res.clearCookie(STEP_UP_COOKIE_NAME, { + ...(this.config.domain ? { domain: this.config.domain } : {}), + }); // Remove the session (fire-and-forget) if (req.token) { @@ -3616,6 +3626,91 @@ export class AuthController extends PuterController { res.status(204).end(); } + // -- Step-up ("elevation"), wired below -------------------------- + // + // Mints the second-factor cookie for a session that re-proves identity: a + // fresh TOTP code when 2FA is enabled, otherwise the account password. + // Privileged endpoints require it on top of the session, so a leaked session + // alone can't exercise them. Accounts with neither credential (no password + // and 2FA disabled) can't elevate. + + async handleElevate(req: Request, res: Response): Promise { + const user = await this.stores.user.getById(req.actor!.user.id!, { + force: true, + }); + if (!user) + throw new HttpError(404, 'User not found.', { + legacyCode: 'not_found', + }); + if (user.suspended) + throw new HttpError(403, 'Account suspended.', { + legacyCode: 'account_suspended', + }); + + if (user.otp_enabled) { + const code = req.body?.code; + if (!code) + throw new HttpError(400, 'code is required.', { + legacyCode: 'bad_request', + fields: { factor: 'otp' }, + }); + if ( + !verifyOtp( + user.username, + user.otp_secret as string, + String(code), + ) + ) + throw new HttpError(401, 'Incorrect code.', { + legacyCode: 'code_mismatch' as never, + fields: { factor: 'otp' }, + }); + } else if (user.password) { + const password = req.body?.password; + if (!password || typeof password !== 'string') + throw new HttpError(400, 'Password is required.', { + legacyCode: 'password_required', + fields: { factor: 'password' }, + }); + const match = await bcrypt.compare( + password, + user.password as string, + ); + if (!match) + throw new HttpError(401, 'Incorrect password.', { + legacyCode: 'password_mismatch', + fields: { factor: 'password' }, + }); + } else { + // Neither credential on file (e.g. an account that only ever + // authenticated through an external identity provider). + throw new HttpError( + 403, + 'This account has no credential to re-authenticate with. Set a password or enable two-factor authentication first.', + { legacyCode: 'elevation_unavailable' as never }, + ); + } + + const token = signStepUpToken(this.services.token, user as never); + res.cookie( + STEP_UP_COOKIE_NAME, + token, + stepUpCookieOptions(this.config), + ); + + // A browser reads its elevation back from the httpOnly cookie and never + // needs the raw value; handing it to page JS would put the second factor + // within reach of an XSS. API clients have no cookie jar, so they get the + // token to send back as `x-puter-elevation`. Both paths proved the same + // password/TOTP — this only avoids needless exposure, it isn't a gate. + const cookieName = this.config.cookie_name ?? 'puter_token'; + const usedSessionCookie = + !!req.token && req.token === req.cookies?.[cookieName]; + res.json( + usedSessionCookie ? { elevated: true } : { elevated: true, token }, + ); + } + // -- Delete own account (user-protected, wired below) ------------ // // Purge S3 objects + fsentries first, then the user row. FK @@ -3628,6 +3723,9 @@ export class AuthController extends PuterController { res.clearCookie(this.config.cookie_name ?? 'puter_token'); res.clearCookie('puter_token_v2'); res.clearCookie('puter_revalidation'); + res.clearCookie(STEP_UP_COOKIE_NAME, { + ...(this.config.domain ? { domain: this.config.domain } : {}), + }); await this.#cascadeDeleteUser(userId); res.json({ success: true }); } @@ -3774,6 +3872,37 @@ export class AuthController extends PuterController { (req, res) => this.handleDeleteOwnUser(req, res), ); + // Step-up. Served on the root origin (browser form posts same-origin) + // and on `api` (SDK/script clients, which have no cookie jar and send a + // bearer). Deliberately NOT cookie-gated: the password/TOTP in the body + // is the control — a stolen token alone can't satisfy it, and it's also + // what makes CSRF a non-issue. `requireUserActor` still keeps app and + // access-token actors out, so an access token can never mint an + // elevation for its issuer. + router.post( + '/auth/elevate', + { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + rateLimit: [ + { + scope: 'elevate', + limit: 10, + window: 15 * 60_000, + key: 'user', + }, + { + scope: 'elevate-ip', + limit: 40, + window: 15 * 60_000, + key: 'ip', + }, + ], + }, + (req, res) => this.handleElevate(req, res), + ); + const webSessionGate = createWebSessionActorGate(); router.post( diff --git a/src/backend/core/http/index.ts b/src/backend/core/http/index.ts index c836a012d..0e90741cc 100644 --- a/src/backend/core/http/index.ts +++ b/src/backend/core/http/index.ts @@ -46,6 +46,16 @@ export { subdomainGate, } from './middleware/gates'; export { createNotFoundHandler } from './middleware/notFoundHandler'; +export { + createStepUpGate, + signStepUpToken, + STEP_UP_COOKIE_NAME, + STEP_UP_PURPOSE, + STEP_UP_SCOPE, + STEP_UP_TTL_SECONDS, + stepUpCookieOptions, + verifyStepUpSession, +} from './middleware/stepUpSession'; export { PuterRouter } from './PuterRouter'; export { PREFIX_METADATA_KEY, diff --git a/src/backend/core/http/middleware/stepUpSession.test.ts b/src/backend/core/http/middleware/stepUpSession.test.ts new file mode 100644 index 000000000..9e3f1415a --- /dev/null +++ b/src/backend/core/http/middleware/stepUpSession.test.ts @@ -0,0 +1,209 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, Response } from 'express'; +import { describe, expect, it, vi } from 'vitest'; +import { TokenService } from '../../../services/auth/TokenService.js'; +import { + createStepUpGate, + signStepUpToken, + STEP_UP_COOKIE_NAME, + verifyStepUpSession, +} from './stepUpSession.js'; + +const V2_SECRET = 'test-v2-secret'; +const USER_UUID = 'a1111111-1111-1111-1111-111111111111'; + +function tokenService(): TokenService { + const config = { + jwt_secret: 'test-v1-secret', + jwt_secret_v2: V2_SECRET, + allow_v1_tokens: true, + } as ConstructorParameters[0]; + const svc = new TokenService( + config, + {} as ConstructorParameters[1], + {} as ConstructorParameters[2], + {} as ConstructorParameters[3], + ); + svc.onServerStart(); + return svc; +} + +function reqWith( + cookie: string | undefined, + actorUuid: string | undefined, + extra: Partial = {}, +): Request { + return { + cookies: cookie ? { [STEP_UP_COOKIE_NAME]: cookie } : {}, + actor: actorUuid ? { user: { uuid: actorUuid } } : undefined, + ...extra, + } as unknown as Request; +} + +describe('verifyStepUpSession', () => { + it('accepts a token bound to the acting user', () => { + const ts = tokenService(); + const token = signStepUpToken(ts, { uuid: USER_UUID }); + expect( + verifyStepUpSession(reqWith(token, USER_UUID), { tokenService: ts }), + ).toBe(true); + }); + + it('rejects a token bound to a different user (cookie alone is useless)', () => { + const ts = tokenService(); + const token = signStepUpToken(ts, { uuid: USER_UUID }); + expect( + verifyStepUpSession( + reqWith(token, 'b2222222-2222-2222-2222-222222222222'), + { tokenService: ts }, + ), + ).toBe(false); + }); + + it('rejects when there is no actor (no live session)', () => { + const ts = tokenService(); + const token = signStepUpToken(ts, { uuid: USER_UUID }); + expect( + verifyStepUpSession(reqWith(token, undefined), { + tokenService: ts, + }), + ).toBe(false); + }); + + it('rejects when the cookie is missing', () => { + const ts = tokenService(); + expect( + verifyStepUpSession(reqWith(undefined, USER_UUID), { + tokenService: ts, + }), + ).toBe(false); + }); + + it('rejects an expired token', () => { + const ts = tokenService(); + // Sign with a lifetime past the verifier's 30s clock tolerance. + const expired = ts.sign( + 'step-up', + { user_uuid: USER_UUID, purpose: 'elevation' }, + { expiresIn: -60 }, + ); + expect( + verifyStepUpSession(reqWith(expired, USER_UUID), { + tokenService: ts, + }), + ).toBe(false); + }); + + it('rejects a token minted under a different scope/purpose (no cross-use)', () => { + const ts = tokenService(); + // A well-formed session-style token must not satisfy the gate. + const authToken = ts.sign('auth', { + type: 'session', + version: '2', + user_uid: USER_UUID, + }); + expect( + verifyStepUpSession(reqWith(authToken, USER_UUID), { + tokenService: ts, + }), + ).toBe(false); + }); +}); + +describe('createStepUpGate', () => { + it('passes a session with a valid elevation cookie', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const token = signStepUpToken(ts, { uuid: USER_UUID }); + const next = vi.fn(); + gate(reqWith(token, USER_UUID), {} as Response, next); + expect(next).toHaveBeenCalledWith(); + }); + + it('rejects a session without an elevation cookie, hinting the factor', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const next = vi.fn(); + const req = reqWith(undefined, USER_UUID, { + actor: { user: { uuid: USER_UUID, otp_enabled: true } }, + } as never); + gate(req, {} as Response, next); + const err = next.mock.calls[0][0]; + expect(err.statusCode).toBe(403); + expect(err.legacyCode).toBe('elevation_required'); + expect(err.fields.factor).toBe('otp'); + }); + + it('hints the password factor when 2FA is off', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const next = vi.fn(); + gate(reqWith(undefined, USER_UUID), {} as Response, next); + expect(next.mock.calls[0][0].fields.factor).toBe('password'); + }); + + // A stolen session can mint a full-access token via + // /auth/create-access-token without re-proving identity, so exempting one + // here would be a way around the gate rather than an exception to it. + it('does NOT exempt full-access personal access tokens', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const next = vi.fn(); + const req = reqWith(undefined, USER_UUID, { + actor: { + user: { uuid: USER_UUID }, + accessToken: { fullAccess: true }, + }, + } as never); + gate(req, {} as Response, next); + expect(next.mock.calls[0][0]?.statusCode).toBe(403); + }); + + it('accepts the elevation via the x-puter-elevation header (API clients)', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const token = signStepUpToken(ts, { uuid: USER_UUID }); + const next = vi.fn(); + const req = { + cookies: {}, + headers: { 'x-puter-elevation': token }, + actor: { user: { uuid: USER_UUID } }, + } as never; + gate(req, {} as Response, next); + expect(next).toHaveBeenCalledWith(); + }); + + it('rejects a header elevation bound to a different user', () => { + const ts = tokenService(); + const gate = createStepUpGate({ tokenService: ts }); + const token = signStepUpToken(ts, { + uuid: 'b2222222-2222-2222-2222-222222222222', + }); + const next = vi.fn(); + const req = { + cookies: {}, + headers: { 'x-puter-elevation': token }, + actor: { user: { uuid: USER_UUID } }, + } as never; + gate(req, {} as Response, next); + expect(next.mock.calls[0][0]?.statusCode).toBe(403); + }); +}); diff --git a/src/backend/core/http/middleware/stepUpSession.ts b/src/backend/core/http/middleware/stepUpSession.ts new file mode 100644 index 000000000..5c25c222b --- /dev/null +++ b/src/backend/core/http/middleware/stepUpSession.ts @@ -0,0 +1,171 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, RequestHandler } from 'express'; +import type { IConfig } from '../../../types'; +import type { UserRow } from '../../../stores/user/UserStore'; +import type { TokenService } from '../../../services/auth/TokenService'; +import { sessionCookieFlags } from '../../../util/cookieFlags'; +import { HttpError } from '../HttpError'; + +// Make sure the `Express.Request.actor` augmentation is in scope. +import '../expressAugmentation'; + +/** + * Step-up ("elevation") sessions — a second factor layered on top of an + * ordinary session for privileged endpoints (`adminOnly` routes). + * + * An ordinary session cookie proves only that someone holds the credential; for + * privileged endpoints that isn't enough, since a leaked session would inherit + * the privilege. Elevation makes the caller re-prove identity — a fresh TOTP + * code when 2FA is enabled, otherwise the account password — via + * `POST /auth/elevate`, which mints the cookie below. + * + * Both halves are required and neither is sufficient: gates demand a live + * session actor AND this proof, and the proof is bound to that actor's + * `user_uuid`. So a stolen session can't elevate itself, and a stolen elevation + * proof is inert without the session. + * + * The proof travels as an httpOnly cookie for browsers, or as the + * `x-puter-elevation` header for API clients (which have no cookie jar). The + * two are equivalent — both are the same signed token, and obtaining either + * requires the password/TOTP. Nothing is exempt from the requirement: there is + * deliberately no carve-out for any credential kind, because any credential a + * stolen session can obtain *without* re-proving identity would be a way around + * this control rather than an exception to it. + * + * The token has its own scope and `purpose` claim and carries no auth `type` + * claim, so `AuthService.authenticate` rejects it — it can never be spent as a + * main auth token even though every scope shares `jwt_secret_v2`. + */ + +export const STEP_UP_COOKIE_NAME = 'puter_elevated'; +export const STEP_UP_HEADER_NAME = 'x-puter-elevation'; +export const STEP_UP_SCOPE = 'step-up'; +export const STEP_UP_PURPOSE = 'elevation'; +export const STEP_UP_TTL_SECONDS = 7 * 24 * 60 * 60; + +interface StepUpPayload { + user_uuid: string; + purpose: string; +} + +/** Sign an elevation token bound to the user's uuid. */ +export function signStepUpToken( + tokenService: TokenService, + user: Pick, +): string { + return tokenService.sign( + STEP_UP_SCOPE, + { user_uuid: user.uuid, purpose: STEP_UP_PURPOSE }, + { expiresIn: STEP_UP_TTL_SECONDS }, + ); +} + +/** + * Cookie flags for the elevation cookie. `domain` and `maxAge` are what + * `sessionCookieFlags` doesn't set: the domain keeps the cookie readable across + * the site's subdomains (privileged endpoints aren't all on one origin), and + * `maxAge` gives the elevation its lifetime. + */ +export function stepUpCookieOptions(config: IConfig): { + httpOnly: true; + sameSite: 'none' | 'lax'; + secure: boolean; + maxAge: number; + domain?: string; +} { + return { + ...sessionCookieFlags(config), + httpOnly: true, + maxAge: STEP_UP_TTL_SECONDS * 1000, + ...(config.domain ? { domain: config.domain } : {}), + }; +} + +/** + * True iff a valid elevation proof (cookie or `x-puter-elevation` header) is + * present AND bound to the acting user. Never throws — a missing/expired/ + * mismatched proof returns false so callers can prompt for the second factor + * instead of erroring. + */ +export function verifyStepUpSession( + req: Request, + deps: { tokenService: TokenService }, +): boolean { + const header = req.headers?.[STEP_UP_HEADER_NAME]; + const token = + req.cookies?.[STEP_UP_COOKIE_NAME] ?? + (typeof header === 'string' ? header : undefined); + const actorUuid = req.actor?.user?.uuid; + if (!token || !actorUuid) return false; + try { + const payload = deps.tokenService.verify( + STEP_UP_SCOPE, + token, + ); + return ( + payload?.purpose === STEP_UP_PURPOSE && + payload.user_uuid === actorUuid + ); + } catch { + return false; + } +} + +/** + * Require an elevated session. Runs after the privilege gate it supplements + * (`adminOnlyGate`), so it only adds the re-authentication requirement. + * + * Deliberately unconditional — no exemptions, no environment check: + * + * - Not env-conditional, so the flow exercised locally is the one that ships. + * - No carve-out for full-access tokens. That looks safe (a deliberately + * minted, header-borne credential) but isn't: `/auth/create-access-token` + * needs only a session, so a stolen session can mint a full-access token + * without ever re-proving identity and walk straight around this gate. + * - No carve-out based on how the credential arrived (cookie vs bearer). The + * holder of a token chooses which header to put it in, so that distinction + * is attacker-controlled and worthless as a gate. + * + * The invariant: reaching a privileged endpoint requires proving the password + * or a TOTP code within the elevation's lifetime. Nothing else substitutes. + * + * A caller without a valid elevation proof is rejected with + * `elevation_required`; `factor` tells the client which credential to collect. + */ +export function createStepUpGate(deps: { + tokenService: TokenService; +}): RequestHandler { + return (req, _res, next) => { + if (verifyStepUpSession(req, deps)) { + next(); + return; + } + next( + new HttpError(403, 'Re-authentication required', { + legacyCode: 'elevation_required', + fields: { + code: 'elevation_required', + factor: req.actor?.user?.otp_enabled ? 'otp' : 'password', + }, + }), + ); + }; +} diff --git a/src/backend/server.ts b/src/backend/server.ts index 727b9712d..5c5a276de 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -46,6 +46,7 @@ import { requireVerifiedGate, subdomainGate, } from './core/http/middleware/gates'; +import { createStepUpGate } from './core/http/middleware/stepUpSession'; import { createNotFoundHandler } from './core/http/middleware/notFoundHandler'; import { requireAntiCsrf, @@ -899,6 +900,12 @@ export class PuterServer { appGated: Boolean(opts.allowedAppIds), }), ); + // An admin username on a leaked session isn't enough — also require + // a recent re-authentication (full-access tokens are exempt; see + // createStepUpGate). + mwChain.push( + createStepUpGate({ tokenService: this.services.token }), + ); } if (opts.allowedAppIds) { diff --git a/src/puter-js/tests/api/suites/auth.suite.ts b/src/puter-js/tests/api/suites/auth.suite.ts index f9302d617..76a6b9baa 100644 --- a/src/puter-js/tests/api/suites/auth.suite.ts +++ b/src/puter-js/tests/api/suites/auth.suite.ts @@ -37,10 +37,40 @@ export default suite('auth', { t.assert.equal(asUser.status, 403); }, - 'admin user passes admin-gated endpoints': async (t) => { + // Admin-gated endpoints need a step-up on top of the session: the admin + // re-proves identity, then replays the elevation as `x-puter-elevation`. + // Without it a plain admin session is refused, so a leaked session alone + // can't reach them. + 'admin session alone is refused by admin-gated endpoints': async (t) => { + const noElevation = await fetch(`${t.env.apiOrigin}/serverInfo`, { + headers: { + Authorization: `Bearer ${t.env.users.admin.token}`, + Origin: t.env.apiOrigin, + }, + }); + t.assert.equal(noElevation.status, 403); + }, + + 'admin user passes admin-gated endpoints after elevating': async (t) => { + const elevate = await fetch(`${t.env.apiOrigin}/auth/elevate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${t.env.users.admin.token}`, + Origin: t.env.apiOrigin, + }, + body: JSON.stringify({ password: t.env.users.admin.password }), + }); + t.assert.equal(elevate.status, 200); + const { token: elevation } = (await elevate.json()) as { + token?: string; + }; + t.assert.ok(elevation, 'elevate response should include a token'); + const asAdmin = await fetch(`${t.env.apiOrigin}/serverInfo`, { headers: { Authorization: `Bearer ${t.env.users.admin.token}`, + 'x-puter-elevation': elevation!, Origin: t.env.apiOrigin, }, });