mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-27 07:36:01 +00:00
feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 to… (#3158)
* feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 token migration
PUT-1019 (AUTH-5): full revoke-endpoint coverage
- /logout: soft-revoke web session + its asset cookies via revokeCascade
(app sessions and access tokens survive)
- POST /auth/revoke-session: cascade per row kind (web/app/access_token/asset)
- POST /auth/revoke-all-sessions: revoke all web rows for user; optional
include_apps=true nuclear option; gated by userProtected (cookie-only)
- revokeAccessToken: soft-revoke matching access_token row in addition to
removing access_token_permissions
- All revokes are UPDATE revoked_at = now(); no DELETE statements remain
PUT-1021 (SDK-1): backend POST /auth/migrate-token
- v1 access_token/app -> mint matching-kind v2 token, idempotent on
(auth_id, kind, token_uid)
- v1 web/session -> 409 { code: "reauth_required" } (interactive relogin only)
- Same-origin / signed-referer hardening; rate-limited per IP and auth_id
- Gated by auth.allow_v1_tokens; emits puter_token_v2 cookie for app-in-browser
DB migrations: mysql_mig_10, sqlite 0053 (sessions.access_token_uid column)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(put-1019): reject self-revoke; enrich list-sessions response
- handleRevokeSession refuses uuid === req.actor.session.uid (use /logout
instead). The cookie that authenticated the call should never be the
target of a self-revoke — the response can't write fresh auth state
and the client ends up with an ambiguous identity. revoke-all-sessions
still has the explicit include_current opt-in for the nuclear case.
- AuthService.listSessions now joins the apps table for kind='app' rows
(returning { uid, name, title, icon } so the manage-sessions UI can
render the authorizing app without a second round trip), surfaces
kind / expires_at / label / last_ip / created_via, and filters out
asset rows (per-cookie children of web rows, revoked transitively via
cascade — surfacing them as standalone entries would be confusing).
- Sort order: current session first, then most-recently-active. UI
relies on this to anchor "you are here" at the top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(put-1019 put-1021): review nits — origin normalization, cookie fallback, types, migration order
B1: createAccessToken.options.expiresIn widened to string | number.
The impl (#hardExpiryFromExpiresIn) and existing callers/tests use
jsonwebtoken-style strings ('1h', '30d'); narrowing to number forced
unsafe casts at every call site. Cast at the single sign() boundary
where jsonwebtoken's typed template-literal SignOptions clashes
with the wider runtime contract.
B2: Inline comment on the DELETE in access_token_permissions. The
AUTH-5 "no DELETE on revoke" rule scoped to the `sessions` table
(where the cascade graph + audit trail matter). Permissions rows
are the grant manifest for an active token — once its session is
soft-revoked they're dead-weight cache entries. A future audit
requirement would land as a `revoked_at` column on this table,
not a behavior change in this PR.
B3: handleMigrateToken now sets the puter_token_v2 cookie (with the
shared sessionCookieFlags + httpOnly) when the migration result
is kind='app'. The endpoint is already gated on Origin so the
caller is by definition in a browser; access tokens deliberately
skip the cookie since they're programmatic.
B4: #isMigrateTokenOriginAllowed normalizes both incoming origin and
config.origin / allowlist entries (trim + strip trailing slash +
lowercase) before equality. A misconfigured `config.origin =
"https://puter.com/"` would otherwise reject every same-origin
browser call.
B5: Replaced 4x `this.config.cookie_name!` non-null assertions in
AuthController with `(this.config.cookie_name ?? 'puter_token')`.
IConfig is `Partial<IConfigOptional>` so cookie_name is undefined
at runtime in some deployments / test setups; the fallback matches
the pattern in userProtected / OIDCController / puterSite.
B6: MySQLDatabaseClient sorts migrations numerically by trailing
integer instead of lexically. Existing files use unpadded names
(`mysql_mig_<N>.sql`), so plain `.sort()` ran mysql_mig_10 before
mysql_mig_2 — a future migration that depended on _2..9 running
first would break. Non-numeric filenames fall through to
localeCompare for determinism.
B7: Restored the docstring for SessionStore.getOrCreateApp's
`opts.auth_id` ("Stable per-user identity (survives re-login);
carried on every v2 JWT so manage-sessions can group by identity")
— the previous edit truncated it to a fragment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test + feat: backend test coverage for PUT-1019/1021 review fixes + worker session methods
Tests
-----
- compareMigrationFilenames (new) covering B6 numeric-sort: confirms
mysql_mig_10.sql lands after mysql_mig_9.sql; non-numeric files sort
after numbered ones; stable for already-ordered input.
- listSessions (AuthService.test.ts): excludes kind="asset" rows;
enriches with kind/expires_at/last_ip/created_via; joins kind="app"
rows with the apps table; sorts current first then by last_activity
desc.
- handleRevokeSession (AuthController.test.ts): refuses self-revoke
with 400; still allows revoking a sibling session.
- handleMigrateToken (AuthController.test.ts): rejects missing/
disallowed Origin; tolerates trailing slash and uppercase Origin
(B4 normalization); returns 409 reauth_required for v1 web tokens;
does NOT set the cookie for access-token migration; DOES set the
puter_token_v2 cookie (httpOnly + sessionCookieFlags) for
app-under-user migration.
- SessionStore tests updated to import APP_WINDOW_SECONDS /
WEB_WINDOW_SECONDS rather than hardcoded 30/90 day values — the
windows just got bumped to 1y and the assertions need to follow
the constant.
Refactor
--------
- MySQLDatabaseClient exports compareMigrationFilenames so the sort
logic is unit-testable in isolation.
Worker tokens
-------------
- AuthService.createWorkerSessionToken(user, meta?): mints a new
kind="web" row tagged meta.worker=true, expires_at =
WORKER_WINDOW_SECONDS, returns { session, token, gui_token }
with worker: true on each JWT.
- AuthService.createWorkerAppToken(actor, appUid): mints a new
kind="app" row tagged meta.worker=true, expires_at =
WORKER_WINDOW_SECONDS, returns an app-under-user JWT with
worker: true. Note the existing idx_sessions_user_app_active
unique index will collide with an existing non-worker app
session for the same (user, app) — future schema work can
carve workers out of that uniqueness.
SessionStore.js: WEB/APP_WINDOW_SECONDS now 1y;
WORKER_WINDOW_SECONDS = 99y added for the worker path.
Full backend suite: 2172 passed / 16 skipped / 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3ae076b73e
commit
ac5eecb7f3
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
@@ -3405,4 +3406,259 @@ describe('AuthController.handleRevokeSession additional branches', () => {
|
||||
const body = res.body as { sessions: unknown[] };
|
||||
expect(Array.isArray(body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to revoke the caller’s OWN current session row (400)', async () => {
|
||||
// PUT-1019 invariant: a self-revoke leaves the client in an
|
||||
// ambiguous identity state because the response can't write
|
||||
// fresh auth state. /logout is the only path that should end
|
||||
// the session you're currently authenticated under.
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||||
const actorWithSession = {
|
||||
...actor,
|
||||
session: { uid: sessionUid },
|
||||
} as Actor;
|
||||
await expect(
|
||||
controller.handleRevokeSession(
|
||||
makeReq({ uuid: sessionUid }, { actor: actorWithSession }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows revoking a DIFFERENT session belonging to the same user', async () => {
|
||||
// Sanity check that the self-revoke guard only blocks the
|
||||
// caller's own uuid — sibling rows must still be revokable
|
||||
// (that's the whole point of manage-sessions).
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const callerSession = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const targetSession = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const actorWithSession = {
|
||||
...actor,
|
||||
session: {
|
||||
uid: (callerSession.session as { uuid: string }).uuid,
|
||||
},
|
||||
} as Actor;
|
||||
const res = makeRes();
|
||||
await controller.handleRevokeSession(
|
||||
makeReq(
|
||||
{ uuid: (targetSession.session as { uuid: string }).uuid },
|
||||
{ actor: actorWithSession },
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { sessions: unknown[] }).sessions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleMigrateToken (PUT-1021 SDK-1) ─────────────────────────────
|
||||
|
||||
describe('AuthController.handleMigrateToken', () => {
|
||||
const TEST_ORIGIN = 'https://migrate.test.local';
|
||||
|
||||
// PuterServer keeps config in a private field (#config), so we go
|
||||
// through the controller — IController stores it as `protected
|
||||
// config` which TS marks but JS doesn't enforce, and the controller
|
||||
// is the actual consumer of #isMigrateTokenOriginAllowed anyway.
|
||||
const controllerConfig = () =>
|
||||
(controller as { config: Record<string, unknown> }).config;
|
||||
|
||||
// Mints a v1-shaped JWT signed under the test server's legacy
|
||||
// secret. The body matches what migrateLegacyToken expects per
|
||||
// `decoded.type`.
|
||||
const mintV1Token = (payload: Record<string, unknown>): string => {
|
||||
const legacy = controllerConfig().jwt_secret as string | undefined;
|
||||
if (!legacy) throw new Error('test config missing jwt_secret');
|
||||
return jwt.sign(payload, legacy);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// Make the origin allow-check pass for these tests. We mutate
|
||||
// the live config because setupTestServer is shared across the
|
||||
// file; the original value is undefined (default config has no
|
||||
// `origin`) so we don't need to restore.
|
||||
controllerConfig().origin = TEST_ORIGIN;
|
||||
});
|
||||
|
||||
it('rejects when the Origin header is missing', async () => {
|
||||
await expect(
|
||||
controller.handleMigrateToken(makeReq({}), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects when the Origin header is not in config.origin or the allowlist', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
await expect(
|
||||
controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: 'https://not-allowed.example',
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('normalizes trailing slash on the request Origin (B4)', async () => {
|
||||
// The Origin header per spec doesn't carry a trailing slash, but
|
||||
// a misconfigured proxy or a deployment with config.origin
|
||||
// ending in `/` would otherwise force every call to reject.
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: `${TEST_ORIGIN}/`, // trailing slash
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
});
|
||||
|
||||
it('normalizes case on the request Origin (B4)', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN.toUpperCase(),
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
});
|
||||
|
||||
it('returns 409 reauth_required for v1 web/session tokens', async () => {
|
||||
// Web tokens never migrate silently — they always go through the
|
||||
// interactive reauth flow (PUT-1023). The body code is what
|
||||
// puter.js / GUI key on; the 409 status is what tells SDK code
|
||||
// "this isn't a generic auth failure, route through reauth".
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'session',
|
||||
user_uid: user.uuid,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
await expect(
|
||||
controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: 'reauth_required',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => {
|
||||
// Access tokens are programmatic — they ride in Authorization
|
||||
// headers, not browser cookies. Setting a cookie here would
|
||||
// confuse cookie-only middleware downstream.
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect(res.cookies.puter_token_v2).toBeUndefined();
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
expect((res.body as { token: string }).token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => {
|
||||
// App tokens DO get a cookie companion — the app runs inside an
|
||||
// iframe in the GUI, and the GUI's cookie-only middleware
|
||||
// authenticates subsequent calls from the iframe via the cookie
|
||||
// rather than the client having to plumb Authorization through
|
||||
// every request.
|
||||
const { user } = await makeUserAndActor();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
const v1 = mintV1Token({
|
||||
type: 'app-under-user',
|
||||
user_uid: user.uuid,
|
||||
app_uid: appUid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('app');
|
||||
const cookie = res.cookies.puter_token_v2;
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie.value).toBe((res.body as { token: string }).token);
|
||||
expect(cookie.opts?.httpOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,10 @@ import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
import { createUserProtectedGate } from '../../core/http/middleware/userProtected.js';
|
||||
import {
|
||||
createSessionCookieGate,
|
||||
createUserProtectedGate,
|
||||
} from '../../core/http/middleware/userProtected.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
ROUTES_METADATA_KEY,
|
||||
@@ -158,7 +161,10 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
password,
|
||||
user.password as string,
|
||||
);
|
||||
if (!passwordMatch) {
|
||||
throw new HttpError(401, 'Incorrect password.', {
|
||||
legacyCode: 'password_mismatch',
|
||||
@@ -210,7 +216,10 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -264,7 +273,10 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -288,7 +300,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
const hashed = hashRecoveryCode(code);
|
||||
const codes = (user.otp_recovery_codes || '')
|
||||
const codes = ((user.otp_recovery_codes as string) || '')
|
||||
.split(',')
|
||||
.filter(Boolean);
|
||||
const idx = codes.indexOf(hashed);
|
||||
@@ -700,7 +712,7 @@ export class AuthController extends PuterController {
|
||||
})
|
||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||
// Clear the session cookie
|
||||
res.clearCookie(this.config.cookie_name!);
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
|
||||
// Remove the session (fire-and-forget)
|
||||
if (req.token) {
|
||||
@@ -711,7 +723,9 @@ export class AuthController extends PuterController {
|
||||
// same path as /user-protected/delete-own-user — so we don't
|
||||
// orphan fsentries/sessions/permissions.
|
||||
if (req.actor?.user && !req.actor.user.email) {
|
||||
const user = await this.stores.user.getByUuid(req.actor.user.uuid);
|
||||
const user = await this.stores.user.getByUuid(
|
||||
req.actor.user.uuid as string,
|
||||
);
|
||||
if (user && user.password === null && user.email === null) {
|
||||
this.#cascadeDeleteUser(user.id).catch((e) => {
|
||||
console.warn('[logout] temp-user cleanup failed:', e);
|
||||
@@ -944,7 +958,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
@@ -956,7 +975,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.stores.user.getByUuid(decoded.user_uid);
|
||||
const user = await this.stores.user.getByUuid(decoded?.user_uid);
|
||||
if (!user || user.email !== decoded.email) {
|
||||
throw new HttpError(400, 'Token is no longer valid.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -968,7 +987,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const exp = decoded.exp;
|
||||
const exp = decoded.exp as number;
|
||||
const time_remaining = exp
|
||||
? Math.max(0, exp - Math.floor(Date.now() / 1000))
|
||||
: 0;
|
||||
@@ -1001,7 +1020,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
email: string;
|
||||
token: string;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
@@ -1741,12 +1765,10 @@ export class AuthController extends PuterController {
|
||||
res.json(sessions);
|
||||
}
|
||||
|
||||
@Post('/auth/revoke-session', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
})
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: an access token must not be able to revoke its own
|
||||
// issuing web session.
|
||||
async handleRevokeSession(req: Request, res: Response): Promise<void> {
|
||||
const { uuid } = req.body;
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
@@ -1754,6 +1776,18 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// The caller's own session row must go through /logout, not a
|
||||
// self-revoke — otherwise the response can't write fresh auth
|
||||
// state and the client ends up with an ambiguous post-revoke
|
||||
// identity. /auth/revoke-all-sessions still supports a separate
|
||||
// `include_current` opt-in for the nuclear case.
|
||||
if (uuid === req.actor!.session?.uid) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Cannot revoke your current session — use /logout instead',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const session = await this.stores.session.getByUuid(uuid);
|
||||
if (session.user_id !== req.actor!.user.id) {
|
||||
throw new HttpError(403, 'Can only revoke your own sessions', {
|
||||
@@ -1765,6 +1799,16 @@ export class AuthController extends PuterController {
|
||||
res.json({ sessions });
|
||||
}
|
||||
|
||||
async handleRevokeAllSessions(req: Request, res: Response): Promise<void> {
|
||||
const { include_current, include_apps } = req.body ?? {};
|
||||
await this.services.auth.revokeAllSessions(req.actor!, {
|
||||
includeCurrent: !!include_current,
|
||||
includeApps: !!include_apps,
|
||||
});
|
||||
const sessions = await this.services.auth.listSessions(req.actor!);
|
||||
res.json({ sessions });
|
||||
}
|
||||
|
||||
// ── Dev app permissions ─────────────────────────────────────────
|
||||
|
||||
@Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true })
|
||||
@@ -2001,6 +2045,74 @@ export class AuthController extends PuterController {
|
||||
|
||||
// ── Access tokens ───────────────────────────────────────────────
|
||||
|
||||
async handleMigrateToken(req: Request, res: Response): Promise<void> {
|
||||
// 1. Origin lock. Reject anything that isn't same-origin to
|
||||
// `config.origin` or in the explicit per-deployment allowlist.
|
||||
// No Origin header → reject (this endpoint is browser-only by
|
||||
// design; server-side callers should re-auth properly).
|
||||
const reqOrigin = req.headers.origin;
|
||||
if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) {
|
||||
throw new HttpError(403, 'Origin not allowed', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract token. Header takes precedence so body-capture logs
|
||||
// (if any) never see the credential.
|
||||
const authHeader = req.headers.authorization;
|
||||
const headerToken =
|
||||
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice('Bearer '.length).trim()
|
||||
: null;
|
||||
const bodyToken =
|
||||
typeof req.body?.token === 'string' ? req.body.token.trim() : null;
|
||||
const v1Token = headerToken || bodyToken;
|
||||
if (!v1Token) {
|
||||
throw new HttpError(400, 'Missing token', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.services.auth.migrateLegacyToken(v1Token, {
|
||||
ip: req.ip,
|
||||
userAgent:
|
||||
typeof req.headers['user-agent'] === 'string'
|
||||
? req.headers['user-agent']
|
||||
: undefined,
|
||||
});
|
||||
// `puter_token_v2` is the cookie companion to v2 app tokens —
|
||||
// the app runs in-browser (we already gated on Origin above) so
|
||||
// the GUI's cookie-only middleware can authenticate subsequent
|
||||
// calls from the same iframe without the client having to
|
||||
// forward Authorization headers. Access tokens are programmatic
|
||||
// (no browser cookie surface) so we deliberately skip them here.
|
||||
if (result.kind === 'app') {
|
||||
res.cookie('puter_token_v2', result.token, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
#isMigrateTokenOriginAllowed(origin: string): boolean {
|
||||
// Origin headers and config values both reach us in inconsistent
|
||||
// shapes (trailing slash, mixed case from misconfigured deploys,
|
||||
// stray whitespace from JSON config edits). Normalize both sides
|
||||
// before equality so a `config.origin` with a trailing slash
|
||||
// doesn't reject every same-origin browser call.
|
||||
const normalize = (raw: string | undefined): string =>
|
||||
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
const incoming = normalize(origin);
|
||||
if (!incoming) return false;
|
||||
if (incoming === normalize(this.config.origin)) return true;
|
||||
const allowlist = (
|
||||
this.config as { allow_migrate_token_origins?: string[] }
|
||||
).allow_migrate_token_origins;
|
||||
if (!Array.isArray(allowlist)) return false;
|
||||
return allowlist.some((entry) => normalize(entry) === incoming);
|
||||
}
|
||||
|
||||
@Post('/auth/create-access-token', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
@@ -2032,10 +2144,10 @@ export class AuthController extends PuterController {
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
@Post('/auth/revoke-access-token', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
})
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: a leaked access token must not be able to silently
|
||||
// revoke its own siblings.
|
||||
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
||||
let { tokenOrUuid } = req.body;
|
||||
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
||||
@@ -2362,7 +2474,7 @@ export class AuthController extends PuterController {
|
||||
user,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.cookie(this.config.cookie_name, sessionToken, {
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
@@ -2378,7 +2490,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
res.clearCookie(this.config.cookie_name!);
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_revalidation');
|
||||
await this.#cascadeDeleteUser(userId);
|
||||
res.json({ success: true });
|
||||
@@ -2525,6 +2637,63 @@ export class AuthController extends PuterController {
|
||||
},
|
||||
(req, res) => this.handleDeleteOwnUser(req, res),
|
||||
);
|
||||
|
||||
const sessionCookieGate = createSessionCookieGate(this.config);
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-session',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
middleware: [sessionCookieGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeSession(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-all-sessions',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
rateLimit: {
|
||||
scope: 'revoke-all-sessions',
|
||||
limit: 10,
|
||||
window: 60 * 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
middleware: [sessionCookieGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeAllSessions(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-access-token',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
antiCsrf: true,
|
||||
middleware: [sessionCookieGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeAccessToken(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/migrate-token',
|
||||
{
|
||||
subdomain: 'api',
|
||||
rateLimit: {
|
||||
scope: 'migrate-token',
|
||||
limit: 20,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
},
|
||||
(req, res) => this.handleMigrateToken(req, res),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────
|
||||
@@ -2636,7 +2805,7 @@ export class AuthController extends PuterController {
|
||||
await this.services.auth.createSessionToken(user as never, meta);
|
||||
|
||||
// HTTP-only cookie gets the session token
|
||||
res.cookie(this.config.cookie_name, sessionToken, {
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user