add support for step-up sessions (#3395)

* add support for step-up sessions

* update step up session
This commit is contained in:
Neal Shah authored and GitHub committed 2026-07-16 13:58:49 -04:00
1 parent 8d7e70cde4
commit 0462ddd6f5
8 files changed
+691 -23

No files matched your search

+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,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', () => {
@@ -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<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 },
);
}
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(
+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,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 <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');
});
// 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);
});
});
@@ -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 <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 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<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 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<StepUpPayload>(
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',
},
}),
);
};
}
+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) {
+31 -1
View File
@@ -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,
},
});