mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 00:47:07 +00:00
add support for step-up sessions
This commit is contained in:
Generated
+1
-22
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user