add support for step-up sessions

This commit is contained in:
Neal Shah
2026-07-16 13:15:57 -04:00
parent 8d7e70cde4
commit d6940e1e00
7 changed files with 573 additions and 22 deletions
+1 -22
View File
@@ -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"
@@ -1154,6 +1154,114 @@ 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).toEqual({ 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).toEqual({ 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('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', () => {
@@ -29,6 +29,12 @@ 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 {
createSessionCookieGate,
createUserProtectedGate,
createWebSessionActorGate,
} from '../../core/http/middleware/userProtected.js';
@@ -964,6 +970,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 +3627,79 @@ 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<void> {
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 },
);
}
res.cookie(
STEP_UP_COOKIE_NAME,
signStepUpToken(this.services.token, user as never),
stepUpCookieOptions(this.config),
);
res.json({ elevated: true });
}
// -- Delete own account (user-protected, wired below) ------------
//
// Purge S3 objects + fsentries first, then the user row. FK
@@ -3628,6 +3712,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 +3861,33 @@ export class AuthController extends PuterController {
(req, res) => this.handleDeleteOwnUser(req, res),
);
// Step-up. Root origin only (no subdomain): the session cookie is sent
// same-origin, so `createSessionCookieGate` can confirm a real browser
// session (not a stolen bearer/access token) is minting the elevation.
router.post(
'/auth/elevate',
{
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',
},
],
middleware: [createSessionCookieGate(this.config)],
},
(req, res) => this.handleElevate(req, res),
);
const webSessionGate = createWebSessionActorGate();
router.post(
+10
View File
@@ -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,
@@ -0,0 +1,176 @@
/**
* 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 <https://www.gnu.org/licenses/>.
*/
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<typeof TokenService>[0];
const svc = new TokenService(
config,
{} as ConstructorParameters<typeof TokenService>[1],
{} as ConstructorParameters<typeof TokenService>[2],
{} as ConstructorParameters<typeof TokenService>[3],
);
svc.onServerStart();
return svc;
}
function reqWith(
cookie: string | undefined,
actorUuid: string | undefined,
extra: Partial<Request> = {},
): 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');
});
it('exempts 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).toHaveBeenCalledWith();
});
});
@@ -0,0 +1,157 @@
/**
* 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 <https://www.gnu.org/licenses/>.
*/
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 cookie, and the cookie is bound to that actor's
* `user_uuid`. So a stolen session can't elevate itself, and a stolen elevation
* cookie is inert without the session.
*
* 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_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<UserRow, 'uuid'>,
): 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 cookie is present AND bound to the acting user.
* Never throws a missing/expired/mismatched cookie returns false so callers
* can prompt for the second factor instead of erroring.
*/
export function verifyStepUpSession(
req: Request,
deps: { tokenService: TokenService },
): boolean {
const cookie = req.cookies?.[STEP_UP_COOKIE_NAME];
const actorUuid = req.actor?.user?.uuid;
if (!cookie || !actorUuid) return false;
try {
const payload = deps.tokenService.verify<StepUpPayload>(
STEP_UP_SCOPE,
cookie,
);
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 not environment-conditional: the gate behaves identically in dev
* and prod, so the flow exercised locally is the one that ships.
*
* The one exemption is a full-access personal access token. The risk being
* closed is a leaked *ambient session cookie*; a full-access token is a
* deliberately minted credential carried in the Authorization header, so it
* can't be forged cross-origin, and demanding a browser cookie for it would
* break non-interactive callers.
*
* Otherwise a session without a valid elevation cookie 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 (req.actor?.accessToken?.fullAccess) {
next();
return;
}
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',
},
}),
);
};
}
+7
View File
@@ -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) {