fix: auth message popups (#3487)

* fix: auth message popups

* remove v1 auth
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-08-01 10:45:16 -07:00
1 parent 6159bc9ae1
commit c8113d7514
28 files changed
+911 -1630

No files matched your search

-2
View File
@@ -5,9 +5,7 @@
"protocol": "http",
"domain": "puter.localhost",
"cookie_name": "puter_auth_token",
"jwt_secret": "dev-jwt-secret-change-me",
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
"allow_v1_tokens": true,
"url_signature_secret": "dev-url-signature-secret-change-me",
"allow_all_host_values": true,
"allow_no_host_header": true,
-4
View File
@@ -73,11 +73,7 @@
// ── Auth / session ──────────────────────────────────────────────────
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
"jwt_secret": "change-me",
"jwt_secret_v2": "change-me",
// Set false to retire v1 tokens entirely (ROLLOUT-1).
"allow_v1_tokens": true,
"url_signature_secret": "change-me",
"cookie_name": "puter_auth_token",
"min_pass_length": 6,
+1 -4
View File
@@ -47,7 +47,6 @@ State lives under `./puter/data/<service>/`.
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MARIADB_PASSWORD=$(openssl rand -hex 32)
S3_SECRET_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
JWT_SECRET_V2=$(openssl rand -hex 64)
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
@@ -78,9 +77,7 @@ cat > puter/config/config.json <<EOF
"private_app_hosting_domain": "app.puter.localhost",
"private_app_hosting_domain_alt": "dev.puter.localhost",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
@@ -134,7 +131,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
Why these knobs:
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
- `jwt_secret_v2` — the HMAC secret Puter signs and verifies auth tokens with (`kid: 'v2'` JWT header). The pre-v2 token format is retired: a token signed with the old `jwt_secret` no longer verifies, and holders are asked to sign in again. If you are upgrading from a release that had `jwt_secret`, drop it from your config — it is ignored.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
@@ -6381,237 +6381,6 @@ describe('AuthController.handleRevokeSession additional branches', () => {
});
});
// ── handleMigrateToken ──────────────────────────────────────────────
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('allows the exchange from an unlisted origin (apps live on arbitrary domains)', async () => {
// puter.js apps run on any third-party domain; the v1 bearer
// token is the credential, so the exchange itself is not
// origin-gated — only cookie issuance is (next test).
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: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
expect((res.body as { token: string }).token).toBeTruthy();
});
it('does NOT set the puter_token_v2 cookie for an app token from an untrusted origin', async () => {
// Cookie planting on the GUI origin is what the allowlist
// prevents: an attacker page may exchange a token it already
// holds, but must not be able to set a session cookie.
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: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('app');
expect((res.body as { token: string }).token).toBeTruthy();
expect(res.cookies.puter_token_v2).toBeUndefined();
});
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. 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);
});
});
// -- auth_id preservation on forced re-login --
+10 -101
View File
@@ -134,12 +134,12 @@ export class AuthController extends PuterController {
});
}
// Browser-only gate, same rule as `handleMigrateToken` below. The
// session id is client-chosen and travels in a link, so it is not a
// secret — the `Origin` header is what actually says who is asking,
// and only a browser is prevented from lying about it. A caller with
// no `Origin` (curl, a server-side fetch) could otherwise collect a
// token minted for someone else's app just by knowing the id.
// Browser-only gate. The session id is client-chosen and travels in a
// link, so it is not a secret — the `Origin` header is what actually
// says who is asking, and only a browser is prevented from lying about
// it. A caller with no `Origin` (curl, a server-side fetch) could
// otherwise collect a token minted for someone else's app just by
// knowing the id.
//
// `"null"` is rejected too: sandboxed iframes and `file://` documents
// serialise their opaque origin that way, and two *unrelated* opaque
@@ -1043,10 +1043,10 @@ export class AuthController extends PuterController {
antiCsrf: true,
})
async handleLogout(req: Request, res: Response): Promise<void> {
// Clear the session cookie + the v2 migrate-token companion
// cookie (set after `/auth/migrate-token` for app-under-user
// iframes). authProbe reads `puter_token_v2` as a fallback, so
// a stale value would re-authenticate the next request.
// Clear the session cookie + `puter_token_v2`. Nothing issues the
// latter any more (it came from the retired token migration), but
// authProbe still reads it as a fallback, so a value left in a
// browser 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
@@ -3363,84 +3363,6 @@ export class AuthController extends PuterController {
// -- Access tokens -----------------------------------------------
async handleMigrateToken(req: Request, res: Response): Promise<void> {
// 1. Browser-only gate. No Origin header → reject (server-side
// callers should re-auth properly). The exchange itself is open
// to any browser origin: puter.js apps live on arbitrary
// third-party domains, the v1 bearer token is the credential,
// and cookies are never read here — so a hostile page learns
// nothing it doesn't already hold. Origin *trust* only decides
// whether the `puter_token_v2` companion cookie is set below.
const reqOrigin = req.headers.origin;
if (!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 —
// it lets the GUI's cookie-only middleware authenticate
// subsequent same-origin calls without the client forwarding
// Authorization headers. Issuing it is gated on the trusted
// origin allowlist: an arbitrary attacker page must not be able
// to plant a session cookie on the GUI origin (login CSRF).
// Untrusted origins still get the token in the JSON body — the
// cookie is only consumed same-origin (see authProbe) so they
// lose nothing. Access tokens are programmatic (no browser
// cookie surface) so we deliberately skip them here.
if (
result.kind === 'app' &&
this.#isMigrateTokenOriginAllowed(reqOrigin)
) {
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,
@@ -4171,19 +4093,6 @@ export class AuthController extends PuterController {
},
(req, res) => this.handleRenameSession(req, res),
);
router.post(
'/auth/migrate-token',
{
rateLimit: {
scope: 'migrate-token',
limit: 20,
window: 15 * 60_000,
key: 'ip',
},
},
(req, res) => this.handleMigrateToken(req, res),
);
}
// -- Private helpers ----------------------------------------------
@@ -18,6 +18,7 @@
*/
import type { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../actor';
@@ -828,13 +829,32 @@ describe('createAuthProbe (integration) — real session token → real actor',
expect(req.tokenAuthFailed).toBeUndefined();
});
it('sets tokenAuthFailed=true for a syntactically valid but garbage token', async () => {
it('asks a garbage token to reauth rather than failing it outright', async () => {
// Nothing without `kid: 'v2'` can be verified since v1 was retired, so
// an unrecognizable token reads as "your token is from before the
// cutover" — the client gets `reauth_required` and can sign in again,
// instead of a bare token-failure 401 it can't act on.
const probe = createAuthProbe({ authService });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer not-a-real-jwt' } }),
);
expect(req.actor).toBeUndefined();
expect(req.requiresReauth?.reason).toBe('token_v1');
});
it('sets tokenAuthFailed=true for a v2 token signed with the wrong secret', async () => {
// The remaining `invalid` path: routed to the v2 secret by its `kid`,
// and rejected there.
const forged = jwt.sign({ type: 'session', user_uid: 'nope' }, 'wrong', {
keyid: 'v2',
});
const probe = createAuthProbe({ authService });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: `Bearer ${forged}` } }),
);
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBe(true);
});
});
@@ -215,11 +215,11 @@ const extractToken = (
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
// available for cross-origin SDK requests.
//
// `puter_token_v2` is the cookie companion to v2 app-under-user
// tokens set by `POST /auth/migrate-token`. We accept it under the
// same same-origin gate as the primary session cookie so a private
// app iframe can authenticate subsequent calls without re-attaching
// an `Authorization` header on every request.
// `puter_token_v2` was the cookie companion to app-under-user tokens
// handed out by the retired token migration. Nothing issues it any more;
// values still sitting in browsers are honored (under the same
// same-origin gate as the primary session cookie) until they expire, and
// logout clears it.
if (!isCrossOriginBrowserRequest(req)) {
if (cookieName) {
const cookieToken = req.cookies?.[cookieName];
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient';
@@ -26,28 +27,29 @@ import type { Actor } from '../../actor';
/**
* Support helpers for the private-app access gate — ported from v1's
* `puterSiteMiddleware.js` (see `origin/main:src/backend/src/routers/
* hosting/puterSiteMiddleware.js`). Split out because the middleware file
* was getting long.
* hosting/puterSiteMiddleware.js`). Split out because the middleware file was
* getting long.
*
* Covers:
* - Host/subdomain parsing against `private_app_hosting_domain(_alt)`.
* - Owned-app detection for hosted sites: matches the subdomain owner's
* apps by `index_url` against the request host. Replaces the older
* `associated_app_id`-trusting path, which was user-writable without
* an ownership check.
* - Bootstrap-token identity resolution (`Authorization: Bearer`,
* `?puter.auth.token=`, `X-Puter-Auth-Token`, referrer query) so
* private-app visitors with a valid session token (but no cookie on
* the private host) can still be identified.
* - Building the redirect URL from a public hosting host (`puter.site`)
* to the private host (`puter.app`) when a private app is being
* served off the wrong domain.
*
* - Host/subdomain parsing against `private_app_hosting_domain(_alt)`.
* - Owned-app detection for hosted sites: matches the subdomain owner's apps by
* `index_url` against the request host. Replaces the older
* `associated_app_id`-trusting path, which was user-writable without an
* ownership check.
* - Bootstrap-token identity resolution (`Authorization: Bearer`,
* `?puter.auth.token=`, `X-Puter-Auth-Token`, referrer query) so private-app
* visitors with a valid session token (but no cookie on the private host) can
* still be identified.
* - Building the redirect URL from a public hosting host (`puter.site`) to the
* private host (`puter.app`) when a private app is being served off the wrong
* domain.
*
* Sticky cookies (`puter.private.asset.token` for private apps,
* `puter.public.hosted.actor.token` for public-hosted actors) are set
* after a visitor passes the gate, and honored on subsequent requests
* to skip the full entitlement lookup. See AuthService
* `createPrivateAssetToken` / `createPublicHostedActorToken`.
* `puter.public.hosted.actor.token` for public-hosted actors) are set after a
* visitor passes the gate, and honored on subsequent requests to skip the full
* entitlement lookup. See AuthService `createPrivateAssetToken` /
* `createPublicHostedActorToken`.
*/
export interface PrivateHostingConfig {
@@ -56,8 +58,8 @@ export interface PrivateHostingConfig {
privateDomains: string[];
/**
* Raw hosting domain values (preserving port, if configured). Used for
* `index_url` candidate generation — the DB stores URLs exactly as the
* app was created, so dev setups with explicit ports like
* `index_url` candidate generation — the DB stores URLs exactly as the app
* was created, so dev setups with explicit ports like
* `app.puter.localhost:4100` must be matched verbatim.
*/
staticDomainsRaw: string[];
@@ -174,17 +176,17 @@ export function subdomainFromHost(
/**
* Resolve "what app does the subdomain owner run on this host?" by matching
* apps owned by `site.user_id` against the request's host variants. Used by
* the puter-site middleware to decide whether the request hits a private
* app (gate) or a public app (sticky cookie), and by the subdomain driver
* to populate the `associated_app` field in API responses.
* apps owned by `site.user_id` against the request's host variants. Used by the
* puter-site middleware to decide whether the request hits a private app (gate)
* or a public app (sticky cookie), and by the subdomain driver to populate the
* `associated_app` field in API responses.
*
* Ownership-anchored on `apps.owner_user_id = site.user_id` so a subdomain
* can never "claim" an app belonging to a different user. The `subdomains`
* row's own `associated_app_id` column is intentionally ignored — it was
* previously user-writable without an ownership check, so any value there
* is either legacy or planted; deriving from `index_url` makes the answer
* fall out of facts the system already verifies at app-create time.
* Ownership-anchored on `apps.owner_user_id = site.user_id` so a subdomain can
* never "claim" an app belonging to a different user. The `subdomains` row's
* own `associated_app_id` column is intentionally ignored — it was previously
* user-writable without an ownership check, so any value there is either legacy
* or planted; deriving from `index_url` makes the answer fall out of facts the
* system already verifies at app-create time.
*/
export async function resolveOwnedAppForHostedSite(opts: {
req: Request;
@@ -314,19 +316,19 @@ export function getBootstrapToken(
}
/**
* Resolve the acting user for a private-app hosted request. Lookup
* order (first hit wins):
* Resolve the acting user for a private-app hosted request. Lookup order (first
* hit wins):
*
* 1. `puter.private.asset.token` cookie — the sticky cookie set
* after a previous successful entitlement check. Must match the
* expected app + subdomain + private host.
* 2. `req.actor` from the auth probe (e.g. main session cookie on
* same-site requests).
* 3. Raw session cookie fallback (cross-site drops the probe's read).
* 4. Bootstrap token from Authorization / query / header / referrer.
* 1. `puter.private.asset.token` cookie — the sticky cookie set after a previous
* successful entitlement check. Must match the expected app + subdomain +
* private host.
* 2. `req.actor` from the auth probe (e.g. main session cookie on same-site
* requests).
* 3. Raw session cookie fallback (cross-site drops the probe's read).
* 4. Bootstrap token from Authorization / query / header / referrer.
*
* Returns `{source: 'none'}` when no identity can be established —
* the caller then renders the login bootstrap page.
* Returns `{source: 'none'}` when no identity can be established — the caller
* then renders the login bootstrap page.
*/
export async function resolvePrivateIdentity(opts: {
req: Request;
@@ -350,9 +352,8 @@ export async function resolvePrivateIdentity(opts: {
// 1. Sticky private-asset cookie. Prefer the v2 cookie name; fall
// back to the legacy dot-style name while the deprecation window
// is open. A v1 (legacy-secret) token verifies but is intentionally
// NOT treated as a valid sticky cookie — we fall through so the
// chain re-mints under the v2 secret on this response.
// is open. A v1-signed token no longer verifies at all, so it lands
// in the catch below and the chain re-mints under v2 on this response.
const v2CookieName = authService.getPrivateAssetCookieNameV2();
const legacyCookieName = authService.getPrivateAssetCookieName();
const privateCookieToken =
@@ -372,15 +373,12 @@ export async function resolvePrivateIdentity(opts: {
expectedPrivateHost,
},
);
if (!claims.legacy) {
return {
source: 'private-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPrivateCookie: true,
};
}
/* legacy cookie: fall through so the caller re-mints v2 */
return {
source: 'private-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPrivateCookie: true,
};
} catch {
/* fall through — stale / mismatched / logged-out cookie */
}
@@ -448,14 +446,13 @@ export async function resolvePrivateIdentity(opts: {
}
/**
* Guard against token confusion across private-app boundaries: an actor
* derived from an app-under-user token carries the *issuing* app's uid,
* which must match the host the request is being made against. A token
* minted for app A — e.g. when the visitor authorized a third-party app —
* must not be honored as identity on app B's private host, even when the
* underlying user happens to have entitlement to B. User-only actors
* (no `actor.app`) are unaffected: a plain session token is portable by
* design.
* Guard against token confusion across private-app boundaries: an actor derived
* from an app-under-user token carries the _issuing_ app's uid, which must
* match the host the request is being made against. A token minted for app A —
* e.g. when the visitor authorized a third-party app — must not be honored as
* identity on app B's private host, even when the underlying user happens to
* have entitlement to B. User-only actors (no `actor.app`) are unaffected: a
* plain session token is portable by design.
*/
function actorMatchesExpectedApp(
actor: Actor,
@@ -467,8 +464,8 @@ function actorMatchesExpectedApp(
}
/**
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the
* sticky `puter.public.hosted.actor.token` cookie first, then the same
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the sticky
* `puter.public.hosted.actor.token` cookie first, then the same
* session/bootstrap fallbacks.
*/
export async function resolvePublicHostedIdentity(opts: {
@@ -510,17 +507,14 @@ export async function resolvePublicHostedIdentity(opts: {
expectedHost,
},
);
if (!claims.legacy) {
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
}
/* legacy cookie: fall through so the caller re-mints v2 */
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
} catch {
/* fall through */
/* fall through — v1-signed cookies land here and get re-minted */
}
}
@@ -587,9 +581,9 @@ export async function resolvePublicHostedIdentity(opts: {
/**
* Turn a request path into a safe reference for `new URL(path, base)`.
* Collapses any run of leading slashes/backslashes into a single `/` so a
* scheme-relative path (`//evil.com`, `/\evil.com`, which the URL parser
* folds to `//`) can't override the base authority and turn the redirect
* into an open redirect. Guarantees exactly one leading slash.
* scheme-relative path (`//evil.com`, `/\evil.com`, which the URL parser folds
* to `//`) can't override the base authority and turn the redirect into an open
* redirect. Guarantees exactly one leading slash.
*/
function normalizeRedirectPath(originalUrl: string | undefined): string {
return '/' + (originalUrl || '/').replace(/^[/\\]+/, '');
@@ -626,8 +620,8 @@ export function buildPrivateHostRedirect(
/**
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
* equivalent URL for a request that landed on the private hosting domain
* but doesn't belong there (non-private app, or no app at all). Used so a
* equivalent URL for a request that landed on the private hosting domain but
* doesn't belong there (non-private app, or no app at all). Used so a
* formerly-paid app that's now free (or a plain hosted site) resolves on
* `puter.site` instead of 404ing on `puter.app`.
*/
@@ -673,10 +667,10 @@ export function buildAppCenterFallback(
// -- Login bootstrap HTML --------------------------------------------
/**
* Minimal HTML page that prompts the visitor to sign in with Puter.
* Uses puter.js's `auth.signIn` to get a token, then redirects back to
* the same URL with `?puter.auth.token=…` so the middleware can resolve
* identity on the next request.
* Minimal HTML page that prompts the visitor to sign in with Puter. Uses
* puter.js's `auth.signIn` to get a token, then redirects back to the same URL
* with `?puter.auth.token=…` so the middleware can resolve identity on the next
* request.
*
* Kept inline (no template engine dependency). Ported from v1's
* `respondPrivateLoginBootstrap` with non-essential bells removed.
+86 -91
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import crypto from 'node:crypto';
@@ -26,21 +27,21 @@ import { HttpError } from '../HttpError.js';
*
* Three backend implementations are registered at boot via
* `configureRateLimit(...)`; they all stay live simultaneously so that
* different routes / driver methods can pick whichever storage best fits
* their access pattern:
* different routes / driver methods can pick whichever storage best fits their
* access pattern:
*
* - `redis`: Redis sorted sets — atomic per key across a cluster.
* Production default; ioredis-mock in dev.
* - `kv`: one row per hit in the system KV (DynamoDB), with TTL.
* `kv.list()` already drops expired rows, so "entries under
* the prefix" == "entries still in the window".
* - `memory`: per-process counters. Capped + actively swept; does not
* coordinate across nodes, so use only for hot, ephemeral
* counters or when redis is absent.
* - `redis`: Redis sorted sets — atomic per key across a cluster. Production
* default; ioredis-mock in dev.
* - `kv`: one row per hit in the system KV (DynamoDB), with TTL. `kv.list()`
* already drops expired rows, so "entries under the prefix" == "entries still
* in the window".
* - `memory`: per-process counters. Capped + actively swept; does not coordinate
* across nodes, so use only for hot, ephemeral counters or when redis is
* absent.
*
* Each backend exports a `check(key, limit, windowMs)` that returns
* `true` (and records the hit) or `false` (rate-limited). Callers select
* one via the `backend` option; omitting it uses the configured default.
* Each backend exports a `check(key, limit, windowMs)` that returns `true` (and
* records the hit) or `false` (rate-limited). Callers select one via the
* `backend` option; omitting it uses the configured default.
*/
// -- Backend names ----------------------------------------------------
@@ -258,9 +259,9 @@ async function acquireKvConcurrent(kv, key, limit) {
// backend is wired for which mode.
/**
* Wrap a backend pair so every rate / acquire call runs inside a span
* tagged with the backend name. Applied at registration, so all gates
* (route middleware, driver helpers, imperative checks) are covered.
* Wrap a backend pair so every rate / acquire call runs inside a span tagged
* with the backend name. Applied at registration, so all gates (route
* middleware, driver helpers, imperative checks) are covered.
*/
function instrumentBackendPair(name, pair) {
const attrs = { 'rate_limit.backend': name };
@@ -295,22 +296,21 @@ let meteringService = null;
/**
* Wire backend implementations. Call once during server boot, after
* clients/stores are built. All backends with their dependency available
* are registered concurrently — a route or driver method picks one
* per-call via the `backend` option. The `default` slot selects the
* fallback for callers that omit `backend`.
* clients/stores are built. All backends with their dependency available are
* registered concurrently — a route or driver method picks one per-call via the
* `backend` option. The `default` slot selects the fallback for callers that
* omit `backend`.
*
* configureRateLimit({ default: 'redis', redis, kv, metering })
* configureRateLimit({ default: 'memory', redis }) // kv routes
* // would fall back
* configureRateLimit() // memory only
* ConfigureRateLimit({ default: 'redis', redis, kv, metering })
* configureRateLimit({ default: 'memory', redis }) // kv routes // would fall
* back configureRateLimit() // memory only
*
* `metering` is optional; pass the MeteringService instance to enable
* `concurrent.bySubscription` overrides. Without it, the top-level
* `limit` applies uniformly regardless of subscription tier.
* `concurrent.bySubscription` overrides. Without it, the top-level `limit`
* applies uniformly regardless of subscription tier.
*
* Throws if `default` names a backend whose dependency is missing — a
* typo in config should surface loudly, not silently downgrade.
* Throws if `default` names a backend whose dependency is missing — a typo in
* config should surface loudly, not silently downgrade.
*/
export function configureRateLimit({
default: defaultName,
@@ -355,10 +355,10 @@ export function listConfiguredRateLimitBackends() {
}
/**
* Resolve the `{ rate, acquire }` backend pair for a named backend.
* Unknown / unconfigured names log once and fall through to the default
* so a typo in a route or driver decorator doesn't 500 every request —
* rate limiting is best-effort security.
* Resolve the `{ rate, acquire }` backend pair for a named backend. Unknown /
* unconfigured names log once and fall through to the default so a typo in a
* route or driver decorator doesn't 500 every request — rate limiting is
* best-effort security.
*/
function resolveBackend(name) {
if (!name) return backends[defaultBackendName];
@@ -375,15 +375,12 @@ function resolveBackend(name) {
/**
* Build a rate-limit key from the request.
*
* Strategies:
* 'fingerprint' — network hash (IP + headers), refined by the client's
* device fingerprint when one was supplied (default).
* Good for unauthenticated endpoints where the same
* IP may serve many users (offices, VPNs).
* 'ip' — bare IP. Simpler but coarser.
* 'user' — actor UUID. Use for authenticated endpoints where
* you want per-account limits regardless of IP.
* function — custom `(req) => string`.
* Strategies: 'fingerprint' — network hash (IP + headers), refined by the
* client's device fingerprint when one was supplied (default). Good for
* unauthenticated endpoints where the same IP may serve many users (offices,
* VPNs). 'ip' — bare IP. Simpler but coarser. 'user' — actor UUID. Use for
* authenticated endpoints where you want per-account limits regardless of IP.
* function — custom `(req) => string`.
*/
function resolveKey(req, scope, strategy) {
const prefix = scope ? `${scope}:` : '';
@@ -459,11 +456,11 @@ function fingerprint(req) {
/**
* Express middleware factory. Reads from the materialised route option:
*
* { rateLimit: { limit: 10, window: 15 * 60_000, key: 'user' } }
* { rateLimit: { limit: 100, window: 60_000, backend: 'memory' } }
* { rateLimit: { limit: 10, window: 15 * 60_000, key: 'user' } } { rateLimit: {
* limit: 100, window: 60_000, backend: 'memory' } }
*
* Rejects with 429. Fails open on backend error — a broken Redis/KV
* shouldn't 500 every request.
* Rejects with 429. Fails open on backend error — a broken Redis/KV shouldn't
* 500 every request.
*/
export function rateLimitGate(opts) {
const {
@@ -506,16 +503,16 @@ export function rateLimitGate(opts) {
// -- Driver-call helper ----------------------------------------------
/**
* Check rate limit for a driver call. Called from DriverController's
* /call handler. Keyed by user + interface:method so different drivers
* and different methods don't crowd each other.
* Check rate limit for a driver call. Called from DriverController's /call
* handler. Keyed by user + interface:method so different drivers and different
* methods don't crowd each other.
*
* `opts` is the resolved per-method spec from the driver's decorator (or
* imperative `rateLimit` field) — see `resolveDriverRateLimit` in
* `drivers/meta.ts`. When `opts` is omitted (driver declares nothing)
* we apply a loose 600/min default that's chatty enough for UI patterns
* (app listings, repeated `puter-apps:es:app:read` during desktop boot,
* kv polling) while still catching runaway loops.
* `drivers/meta.ts`. When `opts` is omitted (driver declares nothing) we apply
* a loose 600/min default that's chatty enough for UI patterns (app listings,
* repeated `puter-apps:es:app:read` during desktop boot, kv polling) while
* still catching runaway loops.
*
* Returns true if allowed, false if rate-limited.
*/
@@ -543,12 +540,11 @@ export async function checkDriverRateLimit(req, ifaceName, method, opts = {}) {
// -- Imperative helper -----------------------------------------------
/**
* Imperative rate-limit check (no middleware shape). For handlers that
* need a second-axis limit after their route-level limit fires —
* e.g. `/auth/migrate-token` limits per IP at the route, then per
* `auth_id` once the v1 token is decoded. Returns true if allowed,
* false if rate-limited. Fails open on backend error, matching the
* rest of this module's policy.
* Imperative rate-limit check (no middleware shape). For handlers that need a
* second-axis limit after their route-level limit fires — e.g. `/login` clamps
* per IP at the route, then tighter still on the requests that carry an
* `auth_id` hint. Returns true if allowed, false if rate-limited. Fails open on
* backend error, matching the rest of this module's policy.
*/
export async function checkRateLimit(key, limit, windowMs, backend) {
const bk = resolveBackend(backend);
@@ -566,13 +562,12 @@ export async function checkRateLimit(key, limit, windowMs, backend) {
// -- Subscription-aware limit resolution -----------------------------
/**
* Per-request limit resolution shared by `rateLimitGate` and
* `concurrencyGate`. The base value is `opts.limit`; if `bySubscription`
* is set and we have an authenticated actor plus a metering service, we
* look up the actor's subscription policy and prefer the matching
* entry. Failure to resolve (no actor, no metering, metering throws)
* falls through to the base — rate / concurrency limiting should never
* *amplify* a request failure path.
* Per-request limit resolution shared by `rateLimitGate` and `concurrencyGate`.
* The base value is `opts.limit`; if `bySubscription` is set and we have an
* authenticated actor plus a metering service, we look up the actor's
* subscription policy and prefer the matching entry. Failure to resolve (no
* actor, no metering, metering throws) falls through to the base — rate /
* concurrency limiting should never _amplify_ a request failure path.
*/
async function resolveSubscriptionLimit(req, opts) {
const base = opts.limit;
@@ -593,14 +588,14 @@ async function resolveSubscriptionLimit(req, opts) {
/**
* Express middleware factory for concurrent in-flight limiting:
*
* { concurrent: { limit: 5, key: 'user' } }
* { concurrent: { limit: 5, bySubscription: { user_free: 2, unlimited: 50 } } }
* { concurrent: { limit: 10, backend: 'redis', scope: 'expensive-op' } }
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
* bySubscription: { user_free: 2, unlimited: 50 } } } { concurrent: { limit:
* 10, backend: 'redis', scope: 'expensive-op' } }
*
* On accept, schedules release on `res.finish` / `res.close` so even
* aborted requests give their slot back. On reject, 429 with the same
* `too_many_requests` legacyCode as the rate gate (clients already
* handle that branch). Fails open on backend error.
* On accept, schedules release on `res.finish` / `res.close` so even aborted
* requests give their slot back. On reject, 429 with the same
* `too_many_requests` legacyCode as the rate gate (clients already handle that
* branch). Fails open on backend error.
*/
export function concurrencyGate(opts) {
const { key: strategy = 'fingerprint', scope, backend } = opts;
@@ -652,17 +647,17 @@ export function concurrencyGate(opts) {
}
/**
* Acquire a concurrent slot for a driver call. Mirrors
* `checkDriverRateLimit` but returns an acquisition handle: caller MUST
* invoke `release()` on the returned object exactly once, even on
* thrown errors — typically in a `finally`. `ok: false` means the slot
* was full; callers should reject with 429 in that case.
* Acquire a concurrent slot for a driver call. Mirrors `checkDriverRateLimit`
* but returns an acquisition handle: caller MUST invoke `release()` on the
* returned object exactly once, even on thrown errors — typically in a
* `finally`. `ok: false` means the slot was full; callers should reject with
* 429 in that case.
*
* `opts` is the resolved per-method spec from the driver's decorator
* (or imperative `concurrent` field). Omitting `opts` (driver declares
* nothing) yields `{ ok: true, release: noop }` — drivers without a
* declared concurrency limit are unbounded, which matches today's
* behaviour. Apply a limit explicitly to opt in.
* `opts` is the resolved per-method spec from the driver's decorator (or
* imperative `concurrent` field). Omitting `opts` (driver declares nothing)
* yields `{ ok: true, release: noop }` — drivers without a declared concurrency
* limit are unbounded, which matches today's behaviour. Apply a limit
* explicitly to opt in.
*/
export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
if (!opts || typeof opts.limit !== 'number') {
@@ -32,9 +32,7 @@ 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,
+28 -227
View File
@@ -240,17 +240,29 @@ describe('AuthService (integration)', () => {
});
});
it('returns { invalid } for a malformed token', async () => {
it('returns { reauth: token_v1 } for a token that is not v2', async () => {
// Since v1 was retired nothing but `kid: 'v2'` can verify, so an
// unrecognizable token is answered with "sign in again" rather than
// a bare failure the client can't act on.
const result = await authService.authenticate('not-a-jwt');
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('token_v1');
});
it('returns { invalid } for a v2-routed token with a bad signature', async () => {
const forged = jwt.sign({ t: 's', uu: 'nope' }, 'wrong-secret', {
keyid: 'v2',
});
const result = await authService.authenticate(forged);
expect(result.invalid).toBe(true);
expect(result.actor).toBeUndefined();
expect(result.reauth).toBeUndefined();
});
it('overlays reauth.token_v1 onto the actor when the token verifies via the legacy secret', async () => {
// Hand-mint a v1-shape token with the legacy secret (no kid).
// Compress the same way TokenService does so it round-trips
// after verify.
it('a v1-signed session token resolves no actor, only a reauth signal', async () => {
// The user's row and session are perfectly healthy; it is the token
// format that is retired, so the answer is "sign in again" and
// never an authenticated actor.
const user = await makeUser();
const { session } = await authService.createSessionToken(user, {});
const legacyJwt = jwt.sign(
@@ -269,10 +281,11 @@ describe('AuthService (integration)', () => {
'dev-jwt-secret-change-me',
);
const result = await authService.authenticate(legacyJwt);
// The actor is resolved (row exists and is active) AND the
// reauth signal fires so SDK clients migrate.
expect(result.actor?.user.uuid).toBe(user.uuid);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('token_v1');
// The advisory hint still comes off the unverified payload, so the
// client can re-attach to the same identity.
expect(result.reauth?.auth_id).toBe(user.uuid);
});
// ── App-under-user verify path ─────────────────────────────
@@ -969,7 +982,7 @@ describe('AuthService (integration)', () => {
});
describe('createWorkerSessionToken / createWorkerAppToken', () => {
// The test config's v2 jwt_secret is the source of truth for
// The test config's `jwt_secret_v2` is the source of truth for
// verifying claims; go through TokenService to mirror how
// production decodes the same tokens.
const decodeAuth = (token: string): Record<string, unknown> => {
@@ -2244,11 +2257,9 @@ describe('AuthService (integration)', () => {
).rejects.toMatchObject({ statusCode: 401 });
});
it('v1 hosted-asset token verifies with legacy:true while allow_v1_tokens is on', async () => {
// Hand-mint a v1-shape hosted-asset token signed with the
// legacy secret. v1 compression uses the same short keys but
// no `kid` header, so TokenService.verify routes it to the
// legacy secret and tags the result `legacy: true`.
it('a v1-signed hosted-asset cookie no longer verifies', async () => {
// The gate treats this as a stale cookie and re-mints under v2, so
// the only requirement here is that it does not verify.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const v1Token = jwt.sign(
@@ -2265,9 +2276,9 @@ describe('AuthService (integration)', () => {
},
'dev-jwt-secret-change-me',
);
const decoded = await authService.verifyPrivateAssetToken(v1Token);
expect(decoded.legacy).toBe(true);
expect(decoded.userUid).toBe(user.uuid);
await expect(
authService.verifyPrivateAssetToken(v1Token),
).rejects.toThrow();
});
});
@@ -2413,214 +2424,4 @@ describe('AuthService (integration)', () => {
).toBeNull();
});
});
// ── migrate-token ───────────────────────────────────────────────
describe('migrateLegacyToken', () => {
// Hand-mint v1 tokens using the same compression dict the
// TokenService's verify path will decompress against.
const encodeUuid = (u: string): string =>
Buffer.from(u.replace(/-/g, ''), 'hex').toString('base64');
const signV1AccessToken = (opts: {
tokenUid: string;
userUid: string;
appUid?: string;
}): string => {
const payload: Record<string, unknown> = {
t: 't',
token_uid: opts.tokenUid,
uu: encodeUuid(opts.userUid),
};
if (opts.appUid) {
payload.au = encodeUuid(
opts.appUid.startsWith('app-')
? opts.appUid.slice('app-'.length)
: opts.appUid,
);
}
return jwt.sign(payload, 'dev-jwt-secret-change-me');
};
const signV1AppToken = (opts: {
userUid: string;
appUid: string;
}): string => {
const stripped = opts.appUid.startsWith('app-')
? opts.appUid.slice('app-'.length)
: opts.appUid;
return jwt.sign(
{
t: 'au',
uu: encodeUuid(opts.userUid),
au: encodeUuid(stripped),
},
'dev-jwt-secret-change-me',
);
};
const signV1SessionToken = (opts: {
userUid: string;
sessionUuid: string;
}): string =>
jwt.sign(
{
t: 's',
u: encodeUuid(opts.sessionUuid),
uu: encodeUuid(opts.userUid),
},
'dev-jwt-secret-change-me',
);
it('migrates a v1 access-token to a v2 token preserving token_uid', async () => {
const user = await makeUser();
const tokenUid = uuidv4();
const v1 = signV1AccessToken({
tokenUid,
userUid: user.uuid,
});
const result = await authService.migrateLegacyToken(v1);
expect(result.kind).toBe('access_token');
expect(result.auth_id).toBe(user.uuid);
expect(typeof result.session_uid).toBe('string');
// v2 token re-verifies and carries the same token_uid +
// a fresh session_uid.
const decoded = server.services.token.verify('auth', result.token) as {
type: string;
token_uid: string;
session_uid: string;
user_uid: string;
};
expect(decoded.type).toBe('access-token');
expect(decoded.token_uid).toBe(tokenUid);
expect(decoded.user_uid).toBe(user.uuid);
expect(decoded.session_uid).toBe(result.session_uid);
});
it('access-token migration is idempotent (same session_uid on retry)', async () => {
const user = await makeUser();
const tokenUid = uuidv4();
const v1 = signV1AccessToken({ tokenUid, userUid: user.uuid });
const first = await authService.migrateLegacyToken(v1);
const second = await authService.migrateLegacyToken(v1);
expect(first.session_uid).toBe(second.session_uid);
});
it('migrates a v1 app-under-user token to a v2 token', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const v1 = signV1AppToken({
userUid: user.uuid,
appUid,
});
const result = await authService.migrateLegacyToken(v1);
expect(result.kind).toBe('app');
expect(result.auth_id).toBe(user.uuid);
const decoded = server.services.token.verify('auth', result.token) as {
type: string;
app_uid: string;
user_uid: string;
session_uid: string;
};
expect(decoded.type).toBe('app-under-user');
expect(decoded.app_uid).toBe(appUid);
expect(decoded.user_uid).toBe(user.uuid);
expect(decoded.session_uid).toBe(result.session_uid);
});
it('app-token migration is idempotent on (user_id, app_uid)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const v1a = signV1AppToken({ userUid: user.uuid, appUid });
const v1b = signV1AppToken({ userUid: user.uuid, appUid });
const first = await authService.migrateLegacyToken(v1a);
const second = await authService.migrateLegacyToken(v1b);
expect(first.session_uid).toBe(second.session_uid);
});
it('returns 409 reauth_required for v1 session tokens', async () => {
const user = await makeUser();
const v1 = signV1SessionToken({
userUid: user.uuid,
sessionUuid: uuidv4(),
});
await expect(
authService.migrateLegacyToken(v1),
).rejects.toMatchObject({
statusCode: 409,
code: 'reauth_required',
});
});
it('rejects v2 tokens with 401 (nothing to migrate)', async () => {
const user = await makeUser();
const v2 = await authService.createAccessToken(
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
[[`user:${user.uuid}:email:read`]],
);
await expect(
authService.migrateLegacyToken(v2),
).rejects.toMatchObject({ statusCode: 401 });
});
it('rejects garbage tokens with 401', async () => {
await expect(
authService.migrateLegacyToken('not-a-jwt'),
).rejects.toMatchObject({ statusCode: 401 });
});
it('returns 410 for app tokens when allow_v1_app_migration=false', async () => {
// Use a scoped server with the flag flipped — toggling
// `this.config` on the shared server would race with other
// tests.
const scopedServer = await setupTestServer({
allow_v1_app_migration: false,
} as never);
try {
const scopedAuth = scopedServer.services.auth as unknown as
AuthService;
const user = await scopedServer.stores.user.create({
username: `mt-${uuidv4().slice(0, 8)}`,
uuid: uuidv4(),
password: null,
email: `mt-${uuidv4().slice(0, 8)}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const appUid = `app-${uuidv4()}`;
const v1App = signV1AppToken({
userUid: user.uuid,
appUid,
});
await expect(
scopedAuth.migrateLegacyToken(v1App),
).rejects.toMatchObject({
statusCode: 410,
code: 'app_migration_disabled',
});
// Access-token migration stays on regardless.
const v1AccessToken = signV1AccessToken({
tokenUid: uuidv4(),
userUid: user.uuid,
});
const ok = await scopedAuth.migrateLegacyToken(v1AccessToken);
expect(ok.kind).toBe('access_token');
} finally {
await scopedServer.shutdown();
}
});
});
});
+13 -267
View File
@@ -21,7 +21,6 @@
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import type { Actor } from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
ASSET_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
@@ -31,8 +30,8 @@ import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { Span } from '../../util/span.js';
import type { puterServices } from '../index';
import { PuterService } from '../types';
import { FULL_API_ACCESS } from '../permission/consts';
import { PuterService } from '../types';
import { V1TokensDisabledError } from './TokenService';
import type {
AccessTokenPayload,
@@ -157,11 +156,11 @@ export class AuthService extends PuterService {
token,
);
} catch (err) {
// v1 tokens disabled — surface a `reauth_required` signal
// with an advisory `auth_id` hint so stragglers on cached
// old bundles see the re-login modal instead of a bare 401.
// The hint is read from the *unverified* payload; it's only
// used to label the response, never to grant access.
// A retired v1 token — surface a `reauth_required` signal with an
// advisory `auth_id` hint so stragglers holding one see the
// re-login modal instead of a bare 401. The hint is read from the
// *unverified* payload; it's only used to label the response, never
// to grant access.
if (err instanceof V1TokensDisabledError) {
const hint = err.payload;
const auth_id =
@@ -172,33 +171,20 @@ export class AuthService extends PuterService {
return { invalid: true };
}
// Legacy tokens (pre-`type` field) aren't supported.
// Tokens predating the `type` field aren't supported.
if (!decoded.type) return { invalid: true };
let result: AuthResult;
switch (decoded.type) {
case 'session':
case 'gui':
result = await this.#actorFromSessionToken(decoded, ctx);
break;
return await this.#actorFromSessionToken(decoded, ctx);
case 'app-under-user':
result = await this.#actorFromAppUnderUserToken(decoded, ctx);
break;
return await this.#actorFromAppUnderUserToken(decoded, ctx);
case 'access-token':
result = await this.#actorFromAccessTokenToken(decoded, ctx);
break;
return await this.#actorFromAccessTokenToken(decoded, ctx);
default:
return { invalid: true };
}
if (decoded.legacy && !result.reauth) {
const authId =
(decoded.auth_id as string | undefined) ??
result.actor?.user?.uuid;
result.reauth = { reason: 'token_v1', auth_id: authId };
}
return result;
}
// -- Session lifecycle --------------------------------------------
@@ -698,212 +684,6 @@ export class AuthService extends PuterService {
}
}
async migrateLegacyToken(
v1Token: string,
_ctx: { ip?: string; userAgent?: string } = {},
): Promise<{
token: string;
session_uid: string;
auth_id: string;
kind: 'access_token' | 'app';
}> {
// 1. Verify under v1 secret. `TokenService.verify` tags v1
// results with `legacy: true`; anything else is either a v2
// token (nothing to migrate) or invalid.
let decoded: AnyTokenPayload;
try {
decoded = this.services.token.verify<AnyTokenPayload>(
'auth',
v1Token,
);
} catch {
throw new HttpError(401, 'Invalid token', {
legacyCode: 'token_invalid',
});
}
if (!decoded.legacy) {
throw new HttpError(401, 'Token is not v1', {
legacyCode: 'token_invalid',
});
}
if (!decoded.type) {
throw new HttpError(401, 'Invalid token type', {
legacyCode: 'token_invalid',
});
}
// 2. Web tokens never migrate silently — they go through the
// interactive reauth flow. The `code` field is what puter.js /
// GUI clients key on; `legacyCode` keeps the body shape valid
// for legacy error readers.
if (decoded.type === 'session' || decoded.type === 'gui') {
throw new HttpError(409, 'Reauthentication required', {
legacyCode: 'unauthorized',
code: 'reauth_required',
});
}
// 3. Branch by kind.
if (decoded.type === 'access-token') {
return this.#migrateAccessToken(decoded as AccessTokenPayload);
}
if (decoded.type === 'app-under-user') {
// App-token migration is the kind that ultimately retires
// — flag-gated independently from the top-level
// `allow_v1_tokens` so access-token migration can stay on
// indefinitely.
const allowAppMigration =
(this.config as { allow_v1_app_migration?: boolean })
.allow_v1_app_migration !== false;
if (!allowAppMigration) {
throw new HttpError(410, 'App-token migration disabled', {
legacyCode: 'unauthorized',
code: 'app_migration_disabled',
});
}
return this.#migrateAppToken(decoded as AppUnderUserTokenPayload);
}
throw new HttpError(401, 'Unsupported token type', {
legacyCode: 'token_invalid',
});
}
async #migrateAccessToken(decoded: AccessTokenPayload): Promise<{
token: string;
session_uid: string;
auth_id: string;
kind: 'access_token';
}> {
if (!decoded.token_uid || !decoded.user_uid) {
throw new HttpError(401, 'Invalid token claims', {
legacyCode: 'token_invalid',
});
}
const user = (await this.stores.user.getByUuid(
decoded.user_uid,
)) as UserRow | null;
if (!user) {
throw new HttpError(401, 'User not found', {
legacyCode: 'unauthorized',
});
}
const auth_id = this.#authIdFor(user);
// Per-auth_id rate limit — second axis beyond the route-level
// per-IP limit. Catches an attacker who has both the token and
// a rotating IP pool.
await this.#enforceMigrateAuthIdLimit(auth_id);
const session = await this.stores.session.findOrCreateLegacyAccessToken(
decoded.token_uid,
{ userId: user.id, auth_id },
);
if (!session) {
throw new HttpError(500, 'Session backfill failed', {
legacyCode: 'internal_error',
});
}
// Mint v2 access token. token_uid is preserved so the existing
// `access_token_permissions` rows (keyed by token_uid) keep
// applying — only the JWT envelope and session-row binding
// change.
const jwtPayload: Record<string, unknown> = {
type: 'access-token',
version: '2',
token_uid: decoded.token_uid,
user_uid: user.uuid,
session_uid: session.uuid as string,
auth_id,
};
if (decoded.app_uid) jwtPayload.app_uid = decoded.app_uid;
const token = this.services.token.sign('auth', jwtPayload);
return {
token,
session_uid: session.uuid as string,
auth_id,
kind: 'access_token',
};
}
async #migrateAppToken(decoded: AppUnderUserTokenPayload): Promise<{
token: string;
session_uid: string;
auth_id: string;
kind: 'app';
}> {
if (!decoded.user_uid || !decoded.app_uid) {
throw new HttpError(401, 'Invalid token claims', {
legacyCode: 'token_invalid',
});
}
const user = (await this.stores.user.getByUuid(
decoded.user_uid,
)) as UserRow | null;
if (!user) {
throw new HttpError(401, 'User not found', {
legacyCode: 'unauthorized',
});
}
const auth_id = this.#authIdFor(user);
await this.#enforceMigrateAuthIdLimit(auth_id);
// Idempotent on `(user_id, app_uid)` via the partial unique
const session = await this.stores.session.getOrCreateApp(
user.id,
decoded.app_uid,
{ auth_id },
);
if (!session) {
throw new HttpError(500, 'Session backfill failed', {
legacyCode: 'internal_error',
});
}
const jwtPayload: Record<string, unknown> = {
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: decoded.app_uid,
session_uid: session.uuid as string,
auth_id,
};
const token = this.services.token.sign('auth', jwtPayload);
return {
token,
session_uid: session.uuid as string,
auth_id,
kind: 'app',
};
}
/**
* Per-`auth_id` rate limit for migrate-token. Keyed on the stable v2
* identity so an attacker rotating IPs but holding one user's v1 token
* still hits a ceiling.
*/
async #enforceMigrateAuthIdLimit(auth_id: string): Promise<void> {
// 20 migrations per 15min per identity matches the per-IP
// route limit — either axis trips first depending on the
// attack shape. Generous enough that a healthy client (one
// app open per device) never sees it.
const ok = await checkRateLimit(
`migrate-token-auth:${auth_id}`,
20,
15 * 60_000,
);
if (!ok) {
throw new HttpError(429, 'Too many migration attempts', {
legacyCode: 'too_many_requests',
fields: { 'retry-after': 900 },
});
}
}
// -- App / origin resolution -------------------------------------
/**
@@ -1373,7 +1153,6 @@ export class AuthService extends PuterService {
subdomain?: string;
privateHost?: string;
authId?: string;
legacy?: boolean;
}> {
const decoded = this.#verifyHostedAssetToken(token, 'private');
this.#assertExpected(
@@ -1421,7 +1200,6 @@ export class AuthService extends PuterService {
subdomain: decoded.subdomain as string | undefined,
privateHost: decoded.host as string | undefined,
authId: decoded.auth_id as string | undefined,
legacy: decoded.legacy === true ? true : undefined,
};
}
@@ -1439,7 +1217,6 @@ export class AuthService extends PuterService {
subdomain?: string;
host?: string;
authId?: string;
legacy?: boolean;
}> {
const decoded = this.#verifyHostedAssetToken(token, 'public');
this.#assertExpected(
@@ -1482,7 +1259,6 @@ export class AuthService extends PuterService {
subdomain: decoded.subdomain as string | undefined,
host: decoded.host as string | undefined,
authId: decoded.auth_id as string | undefined,
legacy: decoded.legacy === true ? true : undefined,
};
}
@@ -1850,14 +1626,7 @@ export class AuthService extends PuterService {
return { reauth: { reason: 'session_expired', auth_id } };
}
let session: SessionRow | null = rawRow;
if (!session && decoded.legacy) {
session = (await this.stores.session.findOrCreateLegacyWeb({
userId: user.id,
auth_id,
})) as SessionRow | null;
}
const session: SessionRow | null = rawRow;
if (!session) return { invalid: true };
@@ -1913,24 +1682,9 @@ export class AuthService extends PuterService {
return { reauth: { reason: 'session_expired', auth_id } };
}
let session: SessionRow | null = rawRow;
const session: SessionRow | null = rawRow;
// Legacy v1: lazy-backfill keyed on (user_id, app_uid).
if (!session && decoded.legacy) {
session = (await this.stores.session.getOrCreateApp(
user.id,
decoded.app_uid,
{ auth_id },
)) as SessionRow | null;
}
if (!session && !decoded.legacy) return { invalid: true };
if (!session && decoded.session) {
session = (await this.stores.session.getByUuid(
decoded.session,
)) as SessionRow | null;
}
if (!session) return { invalid: true };
this.stores.session
.touch({
@@ -1972,14 +1726,6 @@ export class AuthService extends PuterService {
}
if (!rawRow) return { invalid: true };
session = rawRow;
} else if (decoded.legacy) {
session = (await this.stores.session.findOrCreateLegacyAccessToken(
decoded.token_uid,
{ userId: user.id, auth_id },
)) as SessionRow | null;
// If backfill fails (DB write contention etc.) we don't
// strand the legacy token — it falls through to the
// permission-table path that v1 used.
}
let authorizer: Actor;
+43 -64
View File
@@ -20,22 +20,18 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { TokenService } from './TokenService.js';
import { TokenService, V1TokensDisabledError } from './TokenService.js';
const V2_SECRET = 'test-v2-secret';
const V1_SECRET = 'test-v1-secret';
function createTokenService(
overrides: {
jwt_secret?: string;
jwt_secret_v2?: string;
allow_v1_tokens?: boolean;
} = {},
): TokenService {
const config = {
jwt_secret: V1_SECRET,
jwt_secret_v2: V2_SECRET,
allow_v1_tokens: true,
...overrides,
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
@@ -48,16 +44,17 @@ function createTokenService(
return svc;
}
/** Hand-mint a v1-shaped token (no `kid` header) signed with the legacy secret. */
/**
* Hand-mint a token in the retired v1 shape: no `kid` header, signed with the
* secret that used to verify it.
*/
function mintV1Token(payload: Record<string, unknown>): string {
return jwt.sign(payload, V1_SECRET);
}
describe('TokenService.onServerStart', () => {
it('refuses to start without jwt_secret_v2', () => {
const config = {
jwt_secret: V1_SECRET,
} as ConstructorParameters<typeof TokenService>[0];
const config = {} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
@@ -70,7 +67,6 @@ describe('TokenService.onServerStart', () => {
it('refuses to start outside dev with the placeholder secrets from config.default.json', () => {
const config = {
env: 'prod',
jwt_secret: V1_SECRET,
jwt_secret_v2: 'dev-jwt-secret-v2-change-me',
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
@@ -85,7 +81,6 @@ describe('TokenService.onServerStart', () => {
it('refuses to start outside dev with the placeholder url_signature_secret', () => {
const config = {
env: 'prod',
jwt_secret: 'a-real-v1-secret',
jwt_secret_v2: 'a-real-v2-secret',
url_signature_secret: 'dev-url-signature-secret-change-me',
} as ConstructorParameters<typeof TokenService>[0];
@@ -106,7 +101,6 @@ describe('TokenService.onServerStart', () => {
// happens to include those characters must not be refused.
const config = {
env: 'prod',
jwt_secret: 'a-real-v1-secret',
jwt_secret_v2: 'kf83-change-me-not-the-placeholder-9af2',
url_signature_secret: 'another-real-secret',
} as ConstructorParameters<typeof TokenService>[0];
@@ -122,7 +116,6 @@ describe('TokenService.onServerStart', () => {
it('allows the placeholder secrets in dev', () => {
const config = {
env: 'dev',
jwt_secret: 'dev-jwt-secret-change-me',
jwt_secret_v2: 'dev-jwt-secret-v2-change-me',
url_signature_secret: 'dev-url-signature-secret-change-me',
} as ConstructorParameters<typeof TokenService>[0];
@@ -197,7 +190,6 @@ describe('TokenService.sign', () => {
// jwt.sign would get an empty secret and throw `secretOrPrivateKey
// must have a value`, surfacing as a 500.
const config = {
jwt_secret: V1_SECRET,
jwt_secret_v2: V2_SECRET,
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
@@ -269,14 +261,12 @@ describe('TokenService.verify — v2', () => {
});
});
describe('TokenService.verify — v1 fallback', () => {
it('verifies a v1-shaped token and tags result with legacy: true', () => {
describe('TokenService.verify — retired v1 tokens', () => {
// v1 is retired: no secret verifies it any more, and every shape it could
// arrive in has to land on the same structured error so the auth probe can
// answer `reauth_required` instead of a bare 401.
it('rejects a v1-shaped token, carrying the unverified payload as a hint', () => {
const svc = createTokenService();
// v1 stored `session` (short `s`) on app-under-user. Compress manually.
const sessionUuidShort = Buffer.from(
'11111111111111111111111111111111',
'hex',
).toString('base64');
const token = mintV1Token({
t: 'au',
v: '0.0.0',
@@ -288,50 +278,53 @@ describe('TokenService.verify — v1 fallback', () => {
'44444444444444444444444444444444',
'hex',
).toString('base64'),
s: sessionUuidShort,
});
const payload = svc.verify<Record<string, unknown>>('auth', token);
expect(payload).toMatchObject({
let thrown: unknown;
try {
svc.verify('auth', token);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(V1TokensDisabledError);
// Decompressed from the *unverified* payload — advisory only, but it is
// what labels the reauth response.
expect((thrown as V1TokensDisabledError).payload).toMatchObject({
type: 'app-under-user',
legacy: true,
user_uid: '33333333-3333-3333-3333-333333333333',
});
expect(payload.session).toBe(
'11111111-1111-1111-1111-111111111111',
);
});
it('rejects v1 tokens when allow_v1_tokens=false', () => {
const svc = createTokenService({ allow_v1_tokens: false });
const token = mintV1Token({ t: 's', uu: 'whatever' });
expect(() => svc.verify('auth', token)).toThrow(/v1 tokens/);
});
it('rejects a v1 token signed with the wrong secret', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, 'not-the-legacy-secret');
expect(() => svc.verify('auth', token)).toThrow();
});
it('falls back to v1 verify when header `kid` is missing', () => {
it('rejects when the header `kid` is missing', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, V1_SECRET);
const payload = svc.verify<Record<string, unknown>>('auth', token);
expect(payload).toMatchObject({ type: 'session', legacy: true });
expect(() => svc.verify('auth', token)).toThrow(V1TokensDisabledError);
});
it('falls back to v1 verify when header `kid` is an unknown value', () => {
it('rejects when the header `kid` is an unknown value', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, V1_SECRET, { keyid: 'v99' });
const payload = svc.verify<Record<string, unknown>>('auth', token);
expect(payload).toMatchObject({ type: 'session', legacy: true });
expect(() => svc.verify('auth', token)).toThrow(V1TokensDisabledError);
});
it('rejects a v1-shaped token signed with any other secret', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, 'not-the-legacy-secret');
expect(() => svc.verify('auth', token)).toThrow(V1TokensDisabledError);
});
it('rejects a garbage string that is not a JWT at all', () => {
const svc = createTokenService();
expect(() => svc.verify('auth', 'not-a-jwt')).toThrow(
V1TokensDisabledError,
);
});
});
describe('TokenService.verify — algorithm pinning', () => {
// jsonwebtoken has always defaulted to HS256 for string secrets, so
// every token ever minted by Puter (v1 and v2) is HS256 — pinning
// `algorithms: ['HS256']` must not invalidate any existing token.
it('existing tokens keep working: minted v2 and default-signed v1 tokens are HS256 and verify', () => {
// jsonwebtoken has always defaulted to HS256 for string secrets, so every
// token Puter mints is HS256 — pinning `algorithms: ['HS256']` must not
// invalidate a live token.
it('existing tokens keep working: minted tokens are HS256 and verify', () => {
const svc = createTokenService();
const v2 = svc.sign('auth', {
type: 'session',
@@ -343,14 +336,6 @@ describe('TokenService.verify — algorithm pinning', () => {
header: { alg: 'HS256' },
});
expect(() => svc.verify('auth', v2)).not.toThrow();
const v1 = mintV1Token({ t: 's', uu: 'user-uuid' });
expect(jwt.decode(v1, { complete: true })).toMatchObject({
header: { alg: 'HS256' },
});
expect(svc.verify<Record<string, unknown>>('auth', v1)).toMatchObject({
legacy: true,
});
});
it('rejects a v2-routed token signed with a non-HS256 algorithm, even with the right secret', () => {
@@ -361,12 +346,6 @@ describe('TokenService.verify — algorithm pinning', () => {
});
expect(() => svc.verify('auth', token)).toThrow(/algorithm/);
});
it('rejects a v1-routed token signed with a non-HS256 algorithm, even with the right secret', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, V1_SECRET, { algorithm: 'HS512' });
expect(() => svc.verify('auth', token)).toThrow(/algorithm/);
});
});
describe('TokenService payload key handling', () => {
+23 -53
View File
@@ -188,11 +188,11 @@ const COMPRESSION: Record<string, CompressionContext> = {
};
/**
* Thrown by `verify()` when a v1 token is presented while
* `auth.allow_v1_tokens=false`. Carries the **unverified** payload so the auth
* probe can mint a `reauth_required` response with an `auth_id` hint — the hint
* is advisory only (never trusted as identity), so reading it from an unsigned
* payload is safe.
* Thrown by `verify()` for any token that isn't v2 — the v1 format is retired
* and no secret verifies it any more. Carries the **unverified** payload so the
* auth probe can mint a `reauth_required` response with an `auth_id` hint — the
* hint is advisory only (never trusted as identity), so reading it from an
* unsigned payload is safe.
*/
export class V1TokensDisabledError extends Error {
constructor(public readonly payload: Record<string, unknown>) {
@@ -224,12 +224,6 @@ export class TokenService extends PuterService {
get #secretV2(): string {
return this.config.jwt_secret_v2 ?? '';
}
get #secretLegacy(): string {
return this.config.jwt_secret ?? '';
}
get #allowV1Tokens(): boolean {
return this.config.allow_v1_tokens !== false;
}
override onServerStart(): void {
const secretV2 = this.config.jwt_secret_v2;
@@ -250,7 +244,6 @@ export class TokenService extends PuterService {
if (this.config.env !== 'dev') {
for (const [name, value] of [
['jwt_secret_v2', secretV2],
['jwt_secret', this.config.jwt_secret ?? ''],
[
'url_signature_secret',
this.config.url_signature_secret ?? '',
@@ -267,8 +260,6 @@ export class TokenService extends PuterService {
// Note: secrets are exposed via getters over `this.config` (see above),
// not copied into fields here. onServerStart only validates them at
// boot — fail fast on a missing v2 secret or a shipped placeholder.
// The legacy `jwt_secret` stays optional (fresh installs have no v1
// tokens to verify); a missing one is handled at verify time.
}
/**
@@ -293,11 +284,9 @@ export class TokenService extends PuterService {
}
/**
* Verify and decompress. Routes by header `kid`:
*
* - `kid === 'v2'` → verify against the v2 secret.
* - Else → verify against the legacy secret and tag the result with `legacy:
* true`. Rejected outright if `allow_v1_tokens=false`.
* Verify and decompress. Only v2 tokens (`kid: 'v2'`) verify; anything else
* is the retired v1 format and throws `V1TokensDisabledError`, which the
* auth probe turns into a `reauth_required` answer rather than a bare 401.
*
* Throws on invalid signature / expired / malformed (propagates
* `jsonwebtoken`'s errors). Callers in the auth probe should catch and
@@ -320,40 +309,21 @@ export class TokenService extends PuterService {
return this.#decompressPayload(context, payload) as unknown as T;
}
// Legacy / unsigned-kid path.
if (!this.#allowV1Tokens) {
// Surface a structured error so the auth probe can route to a
// `reauth_required` response (with an `auth_id` hint) instead
// of a bare 401 that strands the user. Decompress the
// *unverified* payload — the hint is advisory, never trusted
// as identity.
const rawPayload =
decoded &&
typeof decoded === 'object' &&
decoded.payload &&
typeof decoded.payload === 'object'
? (decoded.payload as Record<string, unknown>)
: {};
const hint = this.#decompressPayload(context, rawPayload);
throw new V1TokensDisabledError(hint);
}
if (!this.#secretLegacy) {
throw new Error(
'v1 token presented but no legacy `jwt_secret` configured',
);
}
const payload = jwt.verify(token, this.#secretLegacy, {
clockTolerance: CLOCK_TOLERANCE_SECONDS,
algorithms: ['HS256'],
}) as Record<string, unknown>;
const decompressed = this.#decompressPayload(
context,
payload,
) as Record<string, unknown>;
// `legacy: true` lets AuthService run the v1-token migration paths
// (lazy session backfill, re-auth signal for web sessions).
decompressed.legacy = true;
return decompressed as unknown as T;
// Anything without `kid: 'v2'` is the retired v1 format. Surface a
// structured error so the auth probe can route to a `reauth_required`
// response (with an `auth_id` hint) instead of a bare 401 that strands
// the user. Decompress the *unverified* payload — the hint is advisory,
// never trusted as identity.
const rawPayload =
decoded &&
typeof decoded === 'object' &&
decoded.payload &&
typeof decoded.payload === 'object'
? (decoded.payload as Record<string, unknown>)
: {};
throw new V1TokensDisabledError(
this.#decompressPayload(context, rawPayload),
);
}
/**
+25 -33
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
// Express `Request` augmentations live in `core/http/expressAugmentation.ts`
@@ -24,22 +25,16 @@
// -- Token payload shapes (after `TokenService.verify` decompression) --
/**
* Base fields every auth token carries.
*
* `session_uid` and `auth_id` are present on v2 tokens (`kid: 'v2'`) and
* absent on v1. `legacy: true` is set by `TokenService.verify` when a
* token verified through the legacy-secret fallback; AuthService keys
* the v1→v2 backfill and re-auth flows off this flag.
* Base fields every auth token carries. Only v2 tokens (`kid: 'v2'`) verify, so
* `session_uid` and `auth_id` are always present on a payload that got here.
*/
interface TokenPayloadBase {
version?: string;
type: TokenType;
/** v2: unified session-row binding (uuid of the `sessions` row). */
/** Unified session-row binding (uuid of the `sessions` row). */
session_uid?: string;
/** v2: stable per-user identity that survives re-login */
/** Stable per-user identity that survives re-login. */
auth_id?: string;
/** Set by TokenService when the token verified via the legacy secret. */
legacy?: boolean;
}
export type TokenType = 'session' | 'gui' | 'app-under-user' | 'access-token';
@@ -48,14 +43,14 @@ export type TokenType = 'session' | 'gui' | 'app-under-user' | 'access-token';
* Session token — issued at login; represents a browser session.
*
* `type === 'session'` is the HTTP-only-cookie flavor; `'gui'` is the same
* shape but served as a response body (e.g., QR login → client-visible
* token). Both resolve to a `UserActor` with `accessToken: null`.
* shape but served as a response body (e.g., QR login → client-visible token).
* Both resolve to a `UserActor` with `accessToken: null`.
*/
export interface SessionTokenPayload extends TokenPayloadBase {
type: 'session' | 'gui';
/**
* Session uuid. v1 tokens carry this as the only session reference;
* v2 tokens carry the same value in both `uuid` and `session_uid`.
* Session uuid. v1 tokens carry this as the only session reference; v2
* tokens carry the same value in both `uuid` and `session_uid`.
*/
uuid: string;
/** User uuid (plain). */
@@ -65,22 +60,21 @@ export interface SessionTokenPayload extends TokenPayloadBase {
/**
* App-under-user token — issued to an app acting on behalf of a user.
*
* v1: `session` carries the web session uuid the app token was minted
* under.
* v2: `session_uid` carries the app's *own* session row uuid (kind='app').
* The (web session, app) parenting is recorded on the row, not the JWT.
* V1: `session` carries the web session uuid the app token was minted under.
* v2: `session_uid` carries the app's _own_ session row uuid (kind='app'). The
* (web session, app) parenting is recorded on the row, not the JWT.
*/
export interface AppUnderUserTokenPayload extends TokenPayloadBase {
type: 'app-under-user';
user_uid: string;
app_uid: string;
/** v1: raw web-session uuid (optional). v2: unused. */
/** V1: raw web-session uuid (optional). v2: unused. */
session?: string;
}
/**
* Access token — issued to a third-party / programmatic caller. Carries a
* token uuid whose permissions are managed in `access_token_permissions`.
* Access token — issued to a third-party / programmatic caller. Carries a token
* uuid whose permissions are managed in `access_token_permissions`.
*/
export interface AccessTokenPayload extends TokenPayloadBase {
type: 'access-token';
@@ -96,9 +90,7 @@ export interface AccessTokenPayload extends TokenPayloadBase {
}
export type AnyTokenPayload =
| SessionTokenPayload
| AppUnderUserTokenPayload
| AccessTokenPayload;
SessionTokenPayload | AppUnderUserTokenPayload | AccessTokenPayload;
// -- Session row (from `sessions` table) ----------------------------
+112 -234
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { v4 as uuidv4 } from 'uuid';
@@ -85,10 +86,10 @@ export class SessionStore extends PuterStore {
#lastUserTouchMs = new Map();
/**
* Look up an active session by its uuid. Returns `null` if not
* found, soft-revoked, or past its `expires_at`. The cached row is
* gated by both `revoked_at` and `expires_at` so a stale-but-
* expired row in cache doesn't grant access.
* Look up an active session by its uuid. Returns `null` if not found,
* soft-revoked, or past its `expires_at`. The cached row is gated by both
* `revoked_at` and `expires_at` so a stale-but- expired row in cache
* doesn't grant access.
*/
async getByUuid(uuid) {
const row = await this.getByUuidAny(uuid);
@@ -116,8 +117,8 @@ export class SessionStore extends PuterStore {
}
/**
* Get sessions for a user. By default returns only active rows;
* pass `{ includeRevoked: true }` to include soft-revoked rows.
* Get sessions for a user. By default returns only active rows; pass `{
* includeRevoked: true }` to include soft-revoked rows.
*/
async getByUserId(userId, { includeRevoked = false } = {}) {
const sql = includeRevoked
@@ -131,21 +132,22 @@ export class SessionStore extends PuterStore {
* Create a new session row.
*
* @param userId - User ID (numeric)
* @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON.
* @param opts.meta - Request-context metadata (IP, UA, etc.) stored as
* JSON.
* @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'.
* @param opts.label - User-editable label for manage-sessions UI.
* @param opts.parent_session_id - uuid of root session, for derived kinds.
* @param opts.parent_session_id - Uuid of root session, for derived kinds.
* @param opts.last_ip - Request IP at creation.
* @param opts.last_user_agent - Request User-Agent at creation.
* @param opts.expires_at - Row-level expiry (unix seconds). NULL means
* no row-level expiry (used for `access_token` rows whose JWT `exp`
* is the truth). Sliding kinds (web/app/asset) get this populated by
* the caller per the lifetime table; `touch()` then slides it.
* @param opts.expires_at - Row-level expiry (unix seconds). NULL means no
* row-level expiry (used for `access_token` rows whose JWT `exp` is the
* truth). Sliding kinds (web/app/asset) get this populated by the caller
* per the lifetime table; `touch()` then slides it.
* @param opts.app_uid - App UID this row authorizes. Only set for
* `kind='app'`; participates in the (user_id, app_uid) idempotency
* index.
* @param opts.legacy_token_uid - v1 token_uid this row backfills.
* Only set for `created_via='legacy_backfill'`.
* @param opts.legacy_token_uid - V1 token_uid this row backfills. Only set
* for `created_via='legacy_backfill'`.
* @param opts.access_token_uid - For `kind='access_token'` v2 rows: the
* `token_uid` claim that lives in `access_token_permissions`. Lets
* raw-uuid revoke reverse-find the session row when no JWT was
@@ -161,10 +163,10 @@ export class SessionStore extends PuterStore {
/**
* Shared INSERT implementation for `create()` and the idempotent
* `getOrCreate*` paths. With `ignoreConflict: true`, only a duplicate-
* key error from the partial unique indexes is swallowed (concurrent
* caller won the race); every other failure — CHECK, NOT NULL, FK,
* type — throws. The caller then re-SELECTs to find the winning row.
* `getOrCreate*` paths. With `ignoreConflict: true`, only a duplicate- key
* error from the partial unique indexes is swallowed (concurrent caller won
* the race); every other failure — CHECK, NOT NULL, FK, type — throws. The
* caller then re-SELECTs to find the winning row.
*/
async #insertSession(
userId,
@@ -263,13 +265,13 @@ export class SessionStore extends PuterStore {
}
/**
* Rename a session's label. Ownership is enforced via the user_id
* filter — a label edit by user A can't touch a row owned by user B
* even if A guesses B's session uuid.
* Rename a session's label. Ownership is enforced via the user_id filter —
* a label edit by user A can't touch a row owned by user B even if A
* guesses B's session uuid.
*
* Returns `true` when a row was updated, `false` when no row matched
* the (uuid, user_id) pair (either the uuid doesn't exist, the row
* belongs to another user, or it's already soft-revoked).
* Returns `true` when a row was updated, `false` when no row matched the
* (uuid, user_id) pair (either the uuid doesn't exist, the row belongs to
* another user, or it's already soft-revoked).
*/
async setLabel(uuid, userId, label) {
if (!uuid || !userId) return false;
@@ -296,12 +298,11 @@ export class SessionStore extends PuterStore {
}
/**
* Soft-revoke a session by uuid. The row remains in the table
* with `revoked_at` set; subsequent `getByUuid` calls treat it
* as not found. Invalidates the uuid cache and every composite
* cache key that pointed at this row (app / legacy-token), so a
* subsequent re-auth doesn't get a stale "already authorized"
* mapping.
* Soft-revoke a session by uuid. The row remains in the table with
* `revoked_at` set; subsequent `getByUuid` calls treat it as not found.
* Invalidates the uuid cache and every composite cache key that pointed at
* this row (app / legacy-token), so a subsequent re-auth doesn't get a
* stale "already authorized" mapping.
*/
async removeByUuid(uuid) {
if (!uuid) return;
@@ -342,9 +343,9 @@ export class SessionStore extends PuterStore {
}
/**
* Soft-revoke a root session and every derived session that
* points back to it via `parent_session_id`. Broadcasts cache
* invalidation for each affected row's uuid + composite keys.
* Soft-revoke a root session and every derived session that points back to
* it via `parent_session_id`. Broadcasts cache invalidation for each
* affected row's uuid + composite keys.
*/
async revokeCascade(rootUuid) {
if (!rootUuid) return;
@@ -352,8 +353,7 @@ export class SessionStore extends PuterStore {
// Read each affected row's identity columns up-front — every
// composite cache mapping (app, legacy-token, legacy-web) must
// be invalidated alongside the uuid key, otherwise a follow-up
// `getOrCreateApp` / `findOrCreateLegacyWeb` would short-circuit
// to the freshly-revoked row.
// `getOrCreateApp` would short-circuit to the freshly-revoked row.
const rows = await this.clients.db.read(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[rootUuid, rootUuid],
@@ -375,11 +375,11 @@ export class SessionStore extends PuterStore {
}
/**
* Active session row whose access-token identity matches `tokenUid`.
* Covers both v2 (`access_token_uid` set at mint) and v1 lazy-backfill
* (`legacy_token_uid` set on first verify) rows so raw-uuid revoke can
* find the row regardless of whether the token was originally v1 or v2.
* Returns `null` if no active row matches.
* Active session row whose access-token identity matches `tokenUid`. Covers
* both v2 (`access_token_uid` set at mint) and v1 lazy-backfill
* (`legacy_token_uid` set on first verify) rows so raw-uuid revoke can find
* the row regardless of whether the token was originally v1 or v2. Returns
* `null` if no active row matches.
*/
async findActiveByAccessTokenUid(tokenUid) {
if (!tokenUid) return null;
@@ -392,17 +392,17 @@ export class SessionStore extends PuterStore {
}
/**
* Idempotent "give me the app session for this (user, app)" lookup.
* Returns the existing active app session if one exists, or creates
* a new one. Concurrent callers converge on a single row via the
* partial unique index `idx_sessions_user_app_active`.
* Idempotent "give me the app session for this (user, app)" lookup. Returns
* the existing active app session if one exists, or creates a new one.
* Concurrent callers converge on a single row via the partial unique index
* `idx_sessions_user_app_active`.
*
* Cache flow:
* 1. Try `sessions:v2:app:<userId>:<appUid>` (full row).
* 2. If miss, SELECT; on hit, warm both composite + uuid cache.
* 3. If still nothing, INSERT (idempotent under concurrency); the
* losing racer falls through to SELECT and finds the winner's
* row.
*
* 1. Try `sessions:v2:app:<userId>:<appUid>` (full row).
* 2. If miss, SELECT; on hit, warm both composite + uuid cache.
* 3. If still nothing, INSERT (idempotent under concurrency); the losing racer
* falls through to SELECT and finds the winner's row.
*
* @param userId - User row id (numeric).
* @param appUid - App UID (string).
@@ -456,21 +456,20 @@ export class SessionStore extends PuterStore {
}
/**
* Idempotent "give me the worker session for this (user, app,
* worker_name)" lookup. Same shape as `getOrCreateApp` but keyed on
* a triple so multiple workers can sit under the same app (each
* with its own `worker_name`) and each gets its own session row.
* Idempotent "give me the worker session for this (user, app, worker_name)"
* lookup. Same shape as `getOrCreateApp` but keyed on a triple so multiple
* workers can sit under the same app (each with its own `worker_name`) and
* each gets its own session row.
*
* `appUid` is allowed null for user-scoped workers — the partial
* unique index treats those distinctly (SQLite via NULL-distinct
* semantics, MySQL/Postgres via COALESCE in the generated key).
* `appUid` is allowed null for user-scoped workers — the partial unique
* index treats those distinctly (SQLite via NULL-distinct semantics,
* MySQL/Postgres via COALESCE in the generated key).
*
* @param userId - User row id (numeric).
* @param opts.appUid - App UID or null for user-scoped workers.
* @param opts.workerName - Per-worker discriminator. Required.
* @param opts.meta - Additional metadata merged into the row's
* `meta` blob alongside the canonical `worker: true` and
* `worker_name` markers.
* @param opts.meta - Additional metadata merged into the row's `meta` blob
* alongside the canonical `worker: true` and `worker_name` markers.
* @param opts.last_ip / opts.last_user_agent - Request context for
* first-time creation. Ignored when a row already exists.
* @param opts.auth_id - Stable per-user identity (survives re-login).
@@ -528,140 +527,18 @@ export class SessionStore extends PuterStore {
return row;
}
/**
* Idempotent "give me the lazy-backfill row for this v1 token_uid"
* lookup. Mirrors `getOrCreateApp` but keys on `legacy_token_uid`.
*/
async findOrCreateLegacyAccessToken(tokenUid, opts = {}) {
if (!tokenUid || !opts.userId) return null;
const cacheKey = this.#cacheKeyLegacyAt(tokenUid);
const now = nowSeconds();
const cached = await this.#readCacheKey(cacheKey);
if (cached && cached.revoked_at == null && !isExpired(cached, now)) {
return cached;
}
const existing = await this.#selectLegacyAccessTokenRow(tokenUid);
if (existing) {
await this.#writeCacheKey(cacheKey, existing);
this.#writeCache(existing).catch(() => {});
return existing;
}
const created = await this.#insertSession(
opts.userId,
{
kind: 'access_token',
parent_session_id: opts.parent_session_id ?? null,
last_ip: opts.last_ip ?? null,
last_user_agent: opts.last_user_agent ?? null,
expires_at: opts.expires_at ?? null,
legacy_token_uid: tokenUid,
created_via: 'legacy_backfill',
auth_id: opts.auth_id ?? null,
},
{ ignoreConflict: true },
);
const winner = await this.#selectLegacyAccessTokenRow(tokenUid);
const row = winner ?? created;
await this.#writeCacheKey(cacheKey, row);
this.#writeCache(row).catch(() => {});
return row;
}
/**
* Best-effort lazy-backfill row for a v1 web session. The keying tuple
* is `(user_id, last_ip, last_user_agent)` — a UA/IP shift on a
* roaming client produces a fresh row, which is the spec's accepted
* trade-off.
*
* No partial unique index exists for this tuple (a UA string is too
* variable to index), so concurrent racers can both INSERT. We
* resolve via an optimistic-lock pass: after our INSERT we re-SELECT
* the oldest matching row; if we lost the race, soft-revoke our own
* row and return the winner so every caller converges on a single
* `session_uuid`. Cheap (one extra SELECT per legacy-backfill mint,
* which only runs on the first contact from a stale v1 client).
*/
async findOrCreateLegacyWeb(opts = {}) {
if (!opts.userId) return null;
const ip = opts.last_ip ?? null;
const ua = opts.last_user_agent ?? null;
const cacheKey = this.#cacheKeyLegacyWeb(opts.userId, ip, ua);
const now = nowSeconds();
const cached = await this.#readCacheKey(cacheKey);
if (cached && cached.revoked_at == null && !isExpired(cached, now)) {
return cached;
}
const coalesceLastIp = this.clients.db.nullCoalesce('`last_ip`', "''");
const coalesceBoundIp = this.clients.db.nullCoalesce('?', "''");
const coalesceLastUserAgent = this.clients.db.nullCoalesce(
'`last_user_agent`',
"''",
);
const coalesceBoundUserAgent = this.clients.db.nullCoalesce('?', "''");
const selectOldest = () =>
this.clients.db.read(
`SELECT * FROM \`sessions\` WHERE \`kind\` = 'web' AND \`user_id\` = ? AND \`created_via\` = 'legacy_backfill' AND ${coalesceLastIp} = ${coalesceBoundIp} AND ${coalesceLastUserAgent} = ${coalesceBoundUserAgent} AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) ORDER BY \`id\` ASC LIMIT 1`,
[opts.userId, ip, ua, now],
);
const rows = await selectOldest();
const existing = this.#normalizeRow(rows[0]);
if (existing) {
await this.#writeCacheKey(cacheKey, existing);
this.#writeCache(existing).catch(() => {});
return existing;
}
const created = await this.create(opts.userId, {
kind: 'web',
last_ip: ip,
last_user_agent: ua,
expires_at: now + WEB_WINDOW_SECONDS,
created_via: 'legacy_backfill',
auth_id: opts.auth_id ?? null,
});
// Optimistic conflict resolution: re-SELECT the oldest row that
// matches the same tuple. If a concurrent racer beat us to the
// INSERT, fold to their row and revoke ours so the (rare) pair
// doesn't both linger for a year. `removeByUuid` is a no-op when
// the row was already revoked by a third party.
const winnerRows = await selectOldest();
const winner = this.#normalizeRow(winnerRows[0]);
if (winner && winner.uuid !== created.uuid) {
await this.removeByUuid(created.uuid);
await this.#writeCacheKey(cacheKey, winner);
this.#writeCache(winner).catch(() => {});
return winner;
}
await this.#writeCacheKey(cacheKey, created);
// uuid cache already warmed by create()
return created;
}
/**
* Bump `last_activity` and slide `expires_at` per the row's kind in a
* single UPDATE. Sliding kinds (web/app/asset) get their `expires_at`
* extended to `now + window`; `access_token` (and unknown kinds) keep
* their existing `expires_at` (hard expiry). The `last_activity < ?`
* guard makes the UPDATE idempotent across nodes so concurrent touches
* don't fight.
* extended to `now + window`; `access_token` (and unknown kinds) keep their
* existing `expires_at` (hard expiry). The `last_activity < ?` guard makes
* the UPDATE idempotent across nodes so concurrent touches don't fight.
*
* When `ip` / `userAgent` are provided and differ from the stored
* values, they are written into `last_ip` / `last_user_agent` in the
* same UPDATE — and the uuid cache is invalidated so the next read
* doesn't serve the pre-roam values. Unchanged values are no-ops at
* the SQL level (the CASE guards keep the column write conditional)
* and skip the cache invalidate.
* When `ip` / `userAgent` are provided and differ from the stored values,
* they are written into `last_ip` / `last_user_agent` in the same UPDATE —
* and the uuid cache is invalidated so the next read doesn't serve the
* pre-roam values. Unchanged values are no-ops at the SQL level (the CASE
* guards keep the column write conditional) and skip the cache invalidate.
*/
async updateActivity(
uuid,
@@ -728,17 +605,22 @@ export class SessionStore extends PuterStore {
/**
* Best-effort throttled activity touch. Updates the session row's
* `last_activity` column and the owning user's `user.last_activity_ts`
* if either hasn't been touched within `TOUCH_THROTTLE_MS`.
* `last_activity` column and the owning user's `user.last_activity_ts` if
* either hasn't been touched within `TOUCH_THROTTLE_MS`.
*
* When `ip` / `userAgent` are passed, they ride along into
* `updateActivity` so a roaming session also refreshes its
* `last_ip` / `last_user_agent`. Throttle still applies — the IP/UA
* fields only get a chance to update once per `TOUCH_THROTTLE_MS`.
* When `ip` / `userAgent` are passed, they ride along into `updateActivity`
* so a roaming session also refreshes its `last_ip` / `last_user_agent`.
* Throttle still applies — the IP/UA fields only get a chance to update
* once per `TOUCH_THROTTLE_MS`.
*
* Callers fire-and-forget — failures are swallowed.
*
* @param {{uuid?: string, userId?: number, ip?: string|null, userAgent?: string|null}} [args]
* @param {{
* uuid?: string;
* userId?: number;
* ip?: string | null;
* userAgent?: string | null;
* }} [args]
*/
async touch({ uuid, userId, ip = null, userAgent = null } = {}) {
const nowMs = Date.now();
@@ -798,14 +680,19 @@ export class SessionStore extends PuterStore {
return `${CACHE_KEY_PREFIX}:app:${userId}:${appUid}`;
}
/**
* Cache key for a row backfilled from a v1 access token. Nothing creates
* these any more, but rows and cached entries from before v1 was retired
* still have to be invalidated on revoke.
*/
#cacheKeyLegacyAt(tokenUid) {
return `${CACHE_KEY_PREFIX}:legacy-at:${tokenUid}`;
}
/**
* Cache key for the (user, app, worker_name) worker-session triple.
* `app_uid` is encoded as empty-string for user-scoped workers so
* the namespace doesn't fracture on NULL.
* `app_uid` is encoded as empty-string for user-scoped workers so the
* namespace doesn't fracture on NULL.
*/
#cacheKeyWorker(userId, appUid, workerName) {
return `${CACHE_KEY_PREFIX}:worker:${userId}:${appUid ?? ''}:${workerName}`;
@@ -813,8 +700,9 @@ export class SessionStore extends PuterStore {
/**
* Cache key for the (legacy-web) backfill lookup. IP and UA are
* percent-encoded into a single key segment so a UA containing `:`
* doesn't fracture the namespace.
* percent-encoded into a single key segment so a UA containing `:` doesn't
* fracture the namespace. Same as `#cacheKeyLegacyAt`: kept for
* invalidating entries that predate the v1 retirement.
*/
#cacheKeyLegacyWeb(userId, ip, ua) {
const tag = encodeURIComponent(`${ip ?? ''}|${ua ?? ''}`);
@@ -822,9 +710,9 @@ export class SessionStore extends PuterStore {
}
/**
* Every cache key currently mapped to a given row. Used by revoke
* paths so a single revocation invalidates every cached view onto
* the same row in lockstep.
* Every cache key currently mapped to a given row. Used by revoke paths so
* a single revocation invalidates every cached view onto the same row in
* lockstep.
*/
#allCacheKeysForRow(row) {
if (!row?.uuid) return [];
@@ -854,11 +742,11 @@ export class SessionStore extends PuterStore {
);
}
}
// Legacy-web backfill rows are cached by (user_id, last_ip,
// last_user_agent) inside `findOrCreateLegacyWeb`. Without this
// branch, revoke would clear the uuid key but leave the composite
// key serving the stale revoked row for up to CACHE_TTL_SECONDS,
// letting a same-IP/UA replay re-authenticate.
// Rows backfilled from a pre-v2 web token were cached by (user_id,
// last_ip, last_user_agent). Nothing creates them any more, but the
// ones that exist still need that composite key invalidated on revoke,
// or it would serve the stale row for up to CACHE_TTL_SECONDS and let a
// same-IP/UA replay re-authenticate.
if (row.kind === 'web' && row.created_via === 'legacy_backfill') {
keys.push(
this.#cacheKeyLegacyWeb(
@@ -906,10 +794,9 @@ export class SessionStore extends PuterStore {
}
/**
* Active app session for (userId, appUid). Matches the partial unique
* index `idx_sessions_user_app_active`. Kept private — public callers
* should go through `getOrCreateApp` so cache and idempotency stay in
* sync.
* Active app session for (userId, appUid). Matches the partial unique index
* `idx_sessions_user_app_active`. Kept private — public callers should go
* through `getOrCreateApp` so cache and idempotency stay in sync.
*/
async #selectAppRow(userId, appUid) {
const now = nowSeconds();
@@ -921,10 +808,10 @@ export class SessionStore extends PuterStore {
}
/**
* Active worker session for (userId, appUid, workerName). Matches
* the partial unique index `idx_sessions_user_worker_active`.
* `appUid` is allowed null for user-scoped workers; COALESCE keeps
* the comparison correct since SQL `= NULL` doesn't match.
* Active worker session for (userId, appUid, workerName). Matches the
* partial unique index `idx_sessions_user_worker_active`. `appUid` is
* allowed null for user-scoped workers; COALESCE keeps the comparison
* correct since SQL `= NULL` doesn't match.
*/
async #selectWorkerRow(userId, appUid, workerName) {
const now = nowSeconds();
@@ -940,15 +827,6 @@ export class SessionStore extends PuterStore {
return this.#normalizeRow(rows[0]);
}
async #selectLegacyAccessTokenRow(tokenUid) {
const now = nowSeconds();
const rows = await this.clients.db.read(
'SELECT * FROM `sessions` WHERE `legacy_token_uid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1',
[tokenUid, now],
);
return this.#normalizeRow(rows[0]);
}
#normalizeRow(row) {
if (!row) return null;
// Meta may be stored as JSON string (SQLite) or already parsed
@@ -334,32 +334,6 @@ describe('SessionStore', () => {
});
});
describe('findOrCreateLegacyAccessToken', () => {
it('creates a kind="access_token" row tagged legacy_backfill', async () => {
const user = await makeUser();
const tokenUid = uuidv4();
const row = await target.findOrCreateLegacyAccessToken(tokenUid, {
userId: user.id,
auth_id: user.uuid,
});
expect(row.kind).toBe('access_token');
expect(row.legacy_token_uid).toBe(tokenUid);
expect(row.created_via).toBe('legacy_backfill');
});
it('is idempotent for the same legacy_token_uid', async () => {
const user = await makeUser();
const tokenUid = uuidv4();
const a = await target.findOrCreateLegacyAccessToken(tokenUid, {
userId: user.id,
});
const b = await target.findOrCreateLegacyAccessToken(tokenUid, {
userId: user.id,
});
expect(a.uuid).toBe(b.uuid);
});
});
describe('getOrCreateWorker', () => {
it('creates a kind="worker" row tagged meta.worker / meta.worker_name', async () => {
const user = await makeUser();
+1 -28
View File
@@ -600,35 +600,8 @@ interface IConfigOptional {
// -- Auth / session ----------------------------------------------
/**
* Legacy HMAC secret for v1 JWTs. New tokens are always signed with
* `jwt_secret_v2`; this value is verify-only and accepted as long as
* `allow_v1_tokens` is true (flipped off to retire v1).
*/
jwt_secret: string;
/** HMAC secret used to sign and verify v2 auth JWTs (`kid: 'v2'`). */
/** HMAC secret used to sign and verify auth JWTs (`kid: 'v2'`). */
jwt_secret_v2: string;
/**
* When false, v1 tokens (no `kid` header) are rejected at verify. Default
* true during the v1→v2 migration window.
*/
allow_v1_tokens: boolean;
/**
* When false, `POST /auth/migrate-token` returns 410 Gone for v1
* `app-under-user` tokens. App-token migration is retired ahead of
* access-token migration — keeping these on separate flags lets ops kill
* apps first and keep API-key migration on indefinitely. Default true.
*/
allow_v1_app_migration: boolean;
/**
* Optional explicit allowlist of `Origin` header values that are trusted by
* `POST /auth/migrate-token` to receive the `puter_token_v2` companion
* cookie. The main `origin` is always trusted. The token exchange itself is
* open to any browser origin (the v1 bearer token is the credential); this
* list only gates cookie issuance, so attacker pages can't plant a session
* cookie on the GUI origin.
*/
allow_migrate_token_origins?: string[];
/**
* Optional extra `Origin` header values allowed to call the routes that
* hand back a session credential (`/login`, `/signup`,
+4 -7
View File
@@ -55,13 +55,10 @@ if ( window.logged_in_users === null )
window.logged_in_users = [];
}
// this sessions's user — prefer the v2 storage key. The legacy key is
// still consulted so that users with a stale v1 token get picked up here
// (and immediately routed through the reauth modal by the first 401
// from the backend).
window.auth_token =
localStorage.getItem('auth_token_v2')
|| localStorage.getItem('auth_token');
// this session's user. Only the current key is read: a token under the
// retired key can't authenticate anything, so its holder starts signed out
// (and the key itself is cleared on the next token write or sign-out).
window.auth_token = localStorage.getItem('auth_token_v2');
try {
window.user = JSON.parse(localStorage.getItem('user'));
} catch (e) {
+10 -11
View File
@@ -32,15 +32,14 @@ import UIWindowLogin from './UI/UIWindowLogin.js';
import UIWindowProgress from './UI/UIWindowProgress.js';
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
// localStorage keys for the GUI session token. v2 is the new key written
// after the v1→v2 cutover. Reads prefer v2; v1 is only kept to drive the
// reauth-modal path when a stale legacy session is found.
window.AUTH_TOKEN_KEY_V1 = 'auth_token';
// localStorage keys for the GUI session token. The retired key is never read
// for authentication — a token in that format no longer verifies — only
// cleared, so it doesn't sit in a browser forever.
window.AUTH_TOKEN_KEY_V2 = 'auth_token_v2';
window.AUTH_TOKEN_KEY_RETIRED = 'auth_token';
window.is_auth = () => {
const stored = localStorage.getItem(window.AUTH_TOKEN_KEY_V2)
|| localStorage.getItem(window.AUTH_TOKEN_KEY_V1);
const stored = localStorage.getItem(window.AUTH_TOKEN_KEY_V2);
if ( stored === null || window.auth_token === null )
{
return false;
@@ -55,10 +54,10 @@ window.is_auth = () => {
* Central handler for `401 { code: 'reauth_required' }`.
*
* The backend `authProbe` middleware emits this 401 shape whenever a
* token is legacy/revoked/expired. The GUI must NOT silently logout:
* token is retired/revoked/expired. The GUI must NOT silently logout:
* that loses window/URL state and surprises the user. Instead we:
* 1. Snapshot enough state to land back where we were after sign-in.
* 2. Clear both the v1 and v2 token keys.
* 2. Clear the token key (and the retired one, if a value lingers).
* 3. Show a soft modal explaining what happened.
* 4. Re-open UIWindowLogin, forwarding `auth_id` so temp users can
* re-attach to the same identity.
@@ -81,7 +80,7 @@ window.handleReauthRequired = async (signal = {}) => {
// same modal on the next boot.
try {
localStorage.removeItem(window.AUTH_TOKEN_KEY_V2);
localStorage.removeItem(window.AUTH_TOKEN_KEY_V1);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
} catch ( e ) { /* ignore */ }
window.auth_token = null;
@@ -583,7 +582,7 @@ window.update_auth_data = async (auth_token, user) => {
// Write the v2 key going forward and clear any lingering v1 key so
// a single localStorage source-of-truth is used.
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
localStorage.removeItem(window.AUTH_TOKEN_KEY_V1);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
// Set http-only session cookie when user is changing.
// This ensures user-protected endpoints, which only refer to the http-only cookie,
@@ -740,7 +739,7 @@ window.logout = () => {
// the backend logout endpoint.
try {
localStorage.removeItem(window.AUTH_TOKEN_KEY_V2);
localStorage.removeItem(window.AUTH_TOKEN_KEY_V1);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
} catch ( e ) { /* ignore */ }
$(document).trigger('logout');
// document.dispatchEvent(new Event("logout", { bubbles: true}));
+5 -3
View File
@@ -1133,14 +1133,14 @@ window.initgui = async function (options) {
if (r.ok) {
const { token } = await r.json();
window.auth_token = token;
// Write the v2 key; drop legacy v1 key.
// Write the current key; drop any value under the retired one.
localStorage.setItem(
window.AUTH_TOKEN_KEY_V2 || 'auth_token_v2',
token,
);
try {
localStorage.removeItem(
window.AUTH_TOKEN_KEY_V1 || 'auth_token',
window.AUTH_TOKEN_KEY_RETIRED || 'auth_token',
);
} catch (e) {
/* ignore */
@@ -1722,7 +1722,9 @@ window.initgui = async function (options) {
window.user = null;
localStorage.removeItem('user');
window.auth_token = null;
localStorage.removeItem('auth_token');
// `window.logout()` above already cleared the current key; this drops
// any value left under the retired one.
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED || 'auth_token');
// close all windows
$('.window').close();
+4
View File
@@ -131,6 +131,10 @@ dist
./build
build
# playwright output from `npm run test:e2e`
test-results/
playwright-report/
# config file
src/config.js
ssl
+138 -138
View File
@@ -95,15 +95,13 @@ class Lock {
// (using defaultGUIOrigin breaks locally-hosted apps)
const PROD_ORIGIN = 'https://puter.com';
// localStorage keys for the auth token. v1 is the legacy key. v2 is
// the new key written after the token rotation in the v1→v2 cutover.
// Reads prefer v2; v1 is only consulted to drive the silent-migration
// path (access_token / app kinds) or — for kind='web' — to fail over
// to the interactive reauth flow.
const STORAGE_KEY_V1 = 'puter.auth.token';
// localStorage key for the auth token. The retired key below is never read —
// only deleted, so a token from before the format change doesn't linger in a
// visitor's browser (see `discardRetiredAuthToken_`).
const STORAGE_KEY_V2 = 'puter.auth.token.v2';
// Records the API origin a stored v2 token was minted against. A stored token
// is only ever replayed to this origin, so a URL-controlled `puter.api_origin`
const STORAGE_KEY_V1 = 'puter.auth.token';
// Records the API origin a stored token was minted against. A stored token is
// only ever replayed to this origin, so a URL-controlled `puter.api_origin`
// can't harvest a previously-stored token and forward it to a foreign origin.
const STORAGE_KEY_ORIGIN_V2 = 'puter.auth.token.origin.v2';
@@ -569,6 +567,18 @@ const puterInit = function () {
enabled: false, // Disabled by default
});
// `/rao` state, set up before the environment branches below
// because those call setAuthToken, which requests `/rao`.
// Lock to prevent multiple requests to `/rao`
this.lock_rao_ = new Lock();
// Promise that resolves when it's okay to request `/rao`
this.p_can_request_rao_ = Promise.resolve();
// Flag that indicates if a request to `/rao` has been made
this.rao_requested_ = false;
// The in-flight boot `/whoami`, awaited by anything that wants the
// cached user without issuing its own request.
this.whoamiCache_ = null;
// === Start :: Modules === //
// The SDK is running in the Puter GUI (i.e. 'gui')
@@ -586,14 +596,8 @@ const puterInit = function () {
);
try {
let selectedAuthToken = bootstrapAuthToken;
let needsSilentMigration = false;
if (bootstrapAuthToken) {
// URL-param tokens may still be v1 (host apps that
// haven't rebuilt yet). Set immediately so submodules
// can run, then attempt silent migration in the
// background.
this.setAuthToken(bootstrapAuthToken);
needsSilentMigration = true;
} else {
// No token in the URL — fall back to a stored token,
// but ONLY if it is allowed for the current API origin.
@@ -601,47 +605,30 @@ const puterInit = function () {
// stored token must be bound to (and matched against) the
// origin it was minted for. A custom (non-default) origin
// additionally requires an explicit binding; an unbound
// legacy token is only honored against the default origin.
// token is only honored against the default origin.
const boundOrigin = this.normalizeStringCandidate(
localStorage.getItem(STORAGE_KEY_ORIGIN_V2),
);
const v2 = this.normalizeAuthTokenCandidate(
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
);
// v1 (legacy) tokens never carry an origin binding.
const v1 = v2
? null
: this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V1),
);
const storedToken = v2 ?? v1;
if (
storedToken &&
this._storedTokenUsableForCurrentOrigin(
v2 ? boundOrigin : null,
)
this._storedTokenUsableForCurrentOrigin(boundOrigin)
) {
this.setAuthToken(storedToken);
selectedAuthToken = storedToken;
if (v1) needsSilentMigration = true;
} else if (storedToken) {
// A token exists but is not valid for this API
// origin (a URL-supplied custom/attacker origin, or
// an unbound legacy token against a custom origin).
// Treat as unauthenticated and force a reauth for
// this origin instead of replaying the token.
// an unbound token against a custom origin). Treat
// as unauthenticated and force a reauth for this
// origin instead of replaying the token.
this._needsOriginReauth = {
reason: 'api_origin_mismatch',
};
}
}
if (needsSilentMigration && selectedAuthToken) {
// Fire-and-forget. On success, setAuthToken inside the
// migration helper updates submodules with the v2
// token. On failure the next 401 reauth_required
// triggers the interactive flow.
this._silentMigrateV1Token(selectedAuthToken);
}
const tokenAppID =
this.getAppIDFromAuthToken(selectedAuthToken);
if (!tokenAppID && !this.appID) {
@@ -677,26 +664,10 @@ const puterInit = function () {
// initialize submodules
this.initSubmodules();
try {
// Prefer the v2 storage key. Fall back to v1 and
// run a silent migration in the background.
const v2 = this.normalizeAuthTokenCandidate(
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
);
if (v2) {
this.setAuthToken(v2);
} else {
const v1 = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V1),
);
if (v1) {
this.setAuthToken(v1);
// For kind='access_token' the backend will swap
// this for a v2 token. For kind='web' it'll
// refuse (409) and the next request bounces us
// through the reauth popup.
this._silentMigrateV1Token(v1);
}
}
if (storedToken) this.setAuthToken(storedToken);
// if appID is already set in localStorage, then we don't need to show the dialog
if (!this.appID && localStorage.getItem('puter.app.id')) {
this.setAppID(localStorage.getItem('puter.app.id'));
@@ -721,12 +692,23 @@ const puterInit = function () {
this.initSubmodules();
}
// Wherever tokens are stored, a value under the retired key is
// dead weight — drop it even when there's no new token to write
// over it (`setAuthToken` handles that case).
if (this.env === 'web' || this.env === 'app') {
this.discardRetiredAuthToken_();
}
// Add prefix logger (needed to happen after modules are initialized)
(async () => {
try {
const whoami = await this.auth.whoami();
// Reuses the boot `/whoami` rather than issuing a second
// one. Nothing to prefix with when there's no token (or the
// lookup failed), same as when this awaited its own call.
const whoami = await this.whoamiCache_;
if (!whoami) return;
const prefix = `[${
whoami?.app_name ?? this.appInstanceID ?? 'HOST'
whoami.app_name ?? this.appInstanceID ?? 'HOST'
}]`;
logger = logger.fields({ prefix });
this.logger = logger;
@@ -740,13 +722,6 @@ const puterInit = function () {
}
})();
// Lock to prevent multiple requests to `/rao`
this.lock_rao_ = new Lock();
// Promise that resolves when it's okay to request `/rao`
this.p_can_request_rao_ = Promise.resolve();
// Flag that indicates if a request to `/rao` has been made
this.rao_requested_ = false;
/** @type {import('../types/modules/networking').Networking} */
this.net = {
/**
@@ -805,7 +780,6 @@ const puterInit = function () {
return;
}
let had_error = false;
try {
const resp = await fetchUrl(`${this.APIOrigin}/rao`, {
method: 'POST',
@@ -813,16 +787,57 @@ const puterInit = function () {
headers: {
Origin: location.origin, // This is ignored in the browser but needed for workers and nodejs
},
// Recording an app open is ours, not the user's: a stale
// token must not turn a page load into a sign-in prompt.
interactiveReauth: false,
});
// Set inside the lock: a caller parked on `p_can_request_rao_`
// acquires it the moment this releases, and would otherwise
// record the same open a second time.
this.rao_requested_ = true;
return await resp.json();
} catch (e) {
had_error = true;
console.error(e);
} finally {
this.lock_rao_.release();
}
if (!had_error) {
this.rao_requested_ = true;
}
/**
* @internal
* Populates `puter.whoami` for callers that read the cached user
* synchronously. Non-interactive for the same reason as `/rao`: this
* runs on every load without the user asking, so a stale token must not
* raise sign-in UI. Callers that need a definite answer (and the prompt
* that comes with it) use `puter.auth.getUser()`.
*
* Uses `fetchUrl` rather than `this.auth` because `setAuthToken` runs
* before `initSubmodules` in the app environment, and carries the token
* explicitly because `includePuterAuth` reads `globalThis.puter`, which
* isn't assigned until the constructor returns.
*
* @returns {Promise<import('../types/modules/auth').User|null>} The
* cached user, or null when there was no token, the token was
* rejected, or the request failed.
*/
async cacheWhoami_() {
if (!this.authToken) return null;
try {
const resp = await fetchUrl(`${this.APIOrigin}/whoami`, {
authToken: this.authToken,
interactiveReauth: false,
logContext: {
service: 'auth',
operation: 'whoami',
params: {},
},
});
if (!resp.ok) return null;
this.whoami = await resp.json();
return this.whoami;
} catch (e) {
// Best-effort cache — a network failure leaves it unset.
return null;
}
}
@@ -917,9 +932,8 @@ const puterInit = function () {
localStorage.removeItem(STORAGE_KEY_V2);
localStorage.removeItem(STORAGE_KEY_ORIGIN_V2);
}
// Always clear the legacy v1 key on a write — once we
// have a v2 token, the v1 one must not linger or it
// would be picked up by older code paths.
// Clear the retired key on every write, so a stale value
// never outlives the token that replaced it.
localStorage.removeItem(STORAGE_KEY_V1);
} catch (error) {
// Handle the error here
@@ -937,9 +951,7 @@ const puterInit = function () {
this.request_rao_();
// perform whoami and cache results
this.getUser().then((user) => {
this.whoami = user;
});
this.whoamiCache_ = this.cacheWhoami_();
};
/**
@@ -987,14 +999,15 @@ const puterInit = function () {
}
};
resetAuthToken = function () {
if (this.env === 'web-worker' || this.env === 'service-worker') {
throw new Error(
'Sign out is not permitted from WebWorkers or ServiceWorkers',
);
}
/**
* Forget the current token, in memory and (on a 3rd-party site or an
* app) in localStorage. Callers own the surrounding policy — emitting
* events, driving reauth — this only drops the value.
*
* @private
*/
_clearAuthToken = function () {
this.authToken = null;
// If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage
if (this.env === 'web' || this.env === 'app') {
try {
localStorage.removeItem(STORAGE_KEY_V2);
@@ -1005,6 +1018,15 @@ const puterInit = function () {
console.error('Error accessing localStorage:', error);
}
}
};
resetAuthToken = function () {
if (this.env === 'web-worker' || this.env === 'service-worker') {
throw new Error(
'Sign out is not permitted from WebWorkers or ServiceWorkers',
);
}
this._clearAuthToken();
this._emitAuthStateChanged();
};
@@ -1038,16 +1060,7 @@ const puterInit = function () {
// Drop the stored token immediately so a failed/canceled reauth
// doesn't leave a poisoned value in localStorage. The new token
// (if reauth succeeds) is written by setAuthToken downstream.
this.authToken = null;
if (this.env === 'web' || this.env === 'app') {
try {
localStorage.removeItem(STORAGE_KEY_V2);
localStorage.removeItem(STORAGE_KEY_ORIGIN_V2);
localStorage.removeItem(STORAGE_KEY_V1);
} catch (e) {
console.error('Error accessing localStorage:', e);
}
}
this._clearAuthToken();
this._emitAuthStateChanged();
this._reauthInflight = (async () => {
@@ -1129,6 +1142,32 @@ const puterInit = function () {
}
};
/**
* @internal
* The non-interactive half of the reauth policy, called by the network
* layer (lib/networkUtils.js) when a request the user didn't initiate
* comes back `401 { code: 'reauth_required' | 'token_auth_failed' }`.
*
* Boot-time telemetry and cache warmers run on every page load, so
* escalating their 401 to `triggerReauth` puts a sign-in popup (or the
* consent dialog, when there's no user activation to open one with) in
* front of a visitor who did nothing but load the page. Instead: forget
* the dead token and announce it, leaving the prompt to the next call
* the user actually makes.
*
* `sentToken` is the token the failed request carried. A reauth may have
* completed while it was in flight, so a token that no longer matches is
* left alone rather than signing the user back out.
*
* @param {{ reason?: string; auth_id?: string; sentToken?: string }} signal
*/
dropStaleAuthToken = function ({ reason, auth_id, sentToken } = {}) {
if (sentToken && sentToken !== this.authToken) return;
this._emitReauthEvent({ reason, auth_id });
this._clearAuthToken();
this._emitAuthStateChanged();
};
_emitReauthEvent = function ({ reason, auth_id }) {
try {
const handlers =
@@ -1166,56 +1205,17 @@ const puterInit = function () {
};
/**
* Best-effort silent v1→v2 token migration via the backend
* `/auth/migrate-token` endpoint. Used at boot when only a legacy
* `puter.auth.token` is present; on success the v2 token is set via
* setAuthToken (which clears the v1 key).
*
* Returns true on successful migration, false otherwise. Failures fall
* through to the existing 401-reauth path: any subsequent API call
* against the v1 token will receive a `reauth_required` and route
* through triggerReauth.
* @internal
* Delete a token left behind under the retired `puter.auth.token` key.
* The backend no longer honors that format, so a visitor holding one is
* simply signed out — sending it would only earn a 401, and leaving it
* in storage would keep tempting later readers.
*/
_silentMigrateV1Token = async function (v1Token) {
if (!v1Token) return false;
discardRetiredAuthToken_ = function () {
try {
// The `puter_token_v2` companion cookie set by this
// endpoint is only consumed by same-origin requests to
// the GUI origin, and the main domain doesn't serve
// credentialed CORS — `include` from a third-party app
// origin fails the preflight outright. Only ask for
// credentials when we're actually on the GUI origin;
// everywhere else the JSON token response is all we need.
const sameOrigin =
globalThis.location?.origin === this.defaultGUIOrigin;
const resp = await fetchUrl(
`${this.defaultGUIOrigin}/auth/migrate-token`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${v1Token}`,
'Content-Type': 'application/json',
},
withCredentials: sameOrigin,
body: JSON.stringify({}),
},
);
if (!resp.ok) {
// 409 = backend refuses silent migration for this kind
// (web sessions). Any caller will then 401 reauth_required
// and the interactive flow takes over.
return false;
}
const data = await resp.json().catch(() => null);
const token = data?.token;
if (typeof token === 'string' && token.length > 0) {
this.setAuthToken(token);
return true;
}
return false;
localStorage.removeItem(STORAGE_KEY_V1);
} catch (e) {
// Network errors etc. — fall back to reauth on next 401.
return false;
// No storage to clean up.
}
};
+67 -6
View File
@@ -30,6 +30,34 @@ const createDeferred = () => {
return { promise, resolve, reject };
};
/**
* A background request's 401: drop the dead token and tell listeners, but never
* raise sign-in UI. The visitor didn't ask for anything, so a popup or consent
* dialog opening on its own reads as the site misbehaving; the next call the
* user actually initiates does the prompting.
*
* @param {Object} resp - The parsed response body.
* @param {string} [sentToken] - The token the failed request carried.
* @returns {{action: 'reject', error: Object}}
*/
function resolveBackgroundReauth (resp, sentToken) {
puter.dropStaleAuthToken({
reason: resp.reason,
auth_id: resp.auth_id,
sentToken,
});
return {
action: 'reject',
error: {
status: 401,
code: resp.code,
reason: resp.reason,
auth_id: resp.auth_id,
message: 'Reauthentication required',
},
};
}
/**
* Shared 401 reauth policy for a parsed response body. Drives the env-specific
* reauth flow on the Puter class and tells the caller what to do next, so the
@@ -37,19 +65,26 @@ const createDeferred = () => {
* utils.js) apply the exact same policy.
*
* Recognised backend signals:
* - `reauth_required` (v2 `authProbe`): legacy v1 tokens, revoked sessions,
* - `reauth_required` (`authProbe`): retired v1 tokens, revoked sessions,
* and expired sessions beyond the silent re-mint window.
* - `token_auth_failed` (legacy `APIError.create('token_auth_failed')`):
* token no longer valid, prompt re-login (web env only).
*
* @param {Object} resp - The parsed response body.
* @param {Object} [opts]
* @param {boolean} [opts.interactive=true] - Whether this request may raise
* sign-in UI. False for requests the user didn't ask for (see
* `resolveBackgroundReauth`).
* @param {string} [opts.sentToken] - The token the failed request carried, so a
* background reauth only discards a token that is still the current one.
* @returns {Promise<{action: 'replay'}|{action: 'reject', error: Object}|null>}
* `replay` when the caller should re-issue the request once with the fresh
* token, `reject` with the error to surface, or `null` when this is not a
* reauth-recoverable 401 and the caller should handle it normally.
*/
async function resolveReauth (resp) {
async function resolveReauth (resp, { interactive = true, sentToken } = {}) {
if ( resp?.code === 'reauth_required' ) {
if ( ! interactive ) return resolveBackgroundReauth(resp, sentToken);
try {
await puter.triggerReauth({
reason: resp.reason,
@@ -70,6 +105,7 @@ async function resolveReauth (resp) {
}
}
if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) {
if ( ! interactive ) return resolveBackgroundReauth(resp, sentToken);
try {
puter.resetAuthToken();
await puter.ui.authenticateWithPuter();
@@ -99,6 +135,9 @@ async function resolveReauth (resp) {
* @param {string} [spec.method='GET']
* @param {Object} [spec.headers] - Extra headers (nullish values skipped).
* @param {boolean} [spec.includePuterAuth=false] - Add a fresh `Authorization: Bearer`.
* @param {string} [spec.authToken] - The token to send when there is no live one
* to read — during construction, before `globalThis.puter` is assigned — or,
* without `includePuterAuth`, a token that isn't the live one at all.
* @param {boolean} [spec.withCredentials=true]
* @param {string} [spec.responseType='']
* @param {Object} [spec.logId] - Pre-built apiCallLogger request id.
@@ -110,6 +149,7 @@ function buildXhr (spec) {
method = 'GET',
headers = {},
includePuterAuth = false,
authToken,
withCredentials = true,
responseType = '',
} = spec;
@@ -119,8 +159,15 @@ function buildXhr (spec) {
xhr.withCredentials = withCredentials;
xhr.responseType = responseType ?? '';
if ( includePuterAuth && globalThis.puter?.authToken ) {
xhr.setRequestHeader('Authorization', `Bearer ${globalThis.puter.authToken}`);
const bearer = includePuterAuth
? ( globalThis.puter?.authToken ?? authToken )
: authToken;
if ( bearer ) {
// Recorded per attempt: a background 401 only discards the token it was
// actually sent with, so a reauth that landed in the meantime keeps the
// fresh one it installed.
spec._sentAuthToken = bearer;
xhr.setRequestHeader('Authorization', `Bearer ${bearer}`);
}
for ( const [ name, value ] of Object.entries(headers) ) {
if ( value !== undefined && value !== null ) {
@@ -428,7 +475,11 @@ async function classifyRetry (outcome, ctx) {
// reauth (401 / token_auth_failed) — one-shot, any method, no backoff.
if ( status === 401 || parsed?.code === 'token_auth_failed' ) {
if ( ! ctx.done.has('reauth') ) {
const reauth = await resolveReauth(parsed);
const spec = xhr?._puterReq;
const reauth = await resolveReauth(parsed, {
interactive: spec?.interactiveReauth !== false,
sentToken: spec?._sentAuthToken,
});
if ( reauth?.action === 'replay' ) { ctx.done.add('reauth'); return { delayMs: 0 }; }
if ( reauth?.action === 'reject' ) outcome.reauthError = reauth.error;
}
@@ -523,6 +574,10 @@ function dedupe (key, factory, { windowMs = 2000 } = {}) {
* @param {string} url - Full request URL (callers own origin composition).
* @param {Object} [opts]
* @param {boolean} [opts.includePuterAuth=false] - Add `Authorization: Bearer <puter.authToken>`.
* @param {string} [opts.authToken] - The Bearer credential to send when the live
* `puter.authToken` can't be read yet (boot-time calls, before the global is
* assigned) or, without `includePuterAuth`, a token that isn't the live one at
* all (one being exchanged for another).
* @param {string} [opts.method='GET']
* @param {Object} [opts.headers] - Extra request headers (undefined/null values skipped).
* @param {string|Blob|ArrayBuffer|FormData|null} [opts.body]
@@ -536,12 +591,17 @@ function dedupe (key, factory, { windowMs = 2000 } = {}) {
* (idempotent methods retry, others don't). Never retries a write.
* @param {boolean|string} [opts.dedupe] - Coalesce concurrent identical in-flight
* requests (reads only): `true` auto-keys by method+url+body, or pass a key.
* @param {boolean} [opts.interactiveReauth=true] - Whether a reauth-recoverable
* 401 may raise sign-in UI. Pass `false` for requests the user didn't ask for
* (boot telemetry, cache warmers): the stale token is dropped silently and the
* 401 surfaces to the caller instead.
* @param {Object} [opts.paginate] - Reserved for a later sprint step (ignored).
* @returns {Promise<PuterResponse>}
*/
function fetchUrl (url, opts = {}) {
const {
includePuterAuth = false,
authToken,
method = 'GET',
headers = {},
body = null,
@@ -551,10 +611,11 @@ function fetchUrl (url, opts = {}) {
logContext,
retry,
dedupe: dedupeOpt,
interactiveReauth = true,
} = opts;
const logId = logContext ?? { service: 'fetchUrl', operation: `${method} ${url}`, params: { url, method } };
const spec = { url, method, headers, includePuterAuth, withCredentials, responseType, body, signal, logId };
const spec = { url, method, headers, includePuterAuth, authToken, withCredentials, responseType, body, signal, logId, interactiveReauth };
// Read-safety: idempotent methods auto-retry; a POST read opts in with
// `retry:true`; nothing retries when `retry:false` (writes/uploads).
+96
View File
@@ -70,6 +70,34 @@ describe('fetchUrl', () => {
expect(xhrs[0]._reqHeaders['authorization']).toBe('Bearer tok-123');
});
it('sends an explicit authToken instead of the live one', async () => {
// The migration endpoint sends a token that is about to be replaced.
globalThis.puter = { authToken: 'live' };
const xhrs = installFakeXHR(respond({ body: {} }));
await fetchUrl('https://api.example/migrate', { method: 'POST', authToken: 'explicit' });
expect(xhrs[0]._reqHeaders['authorization']).toBe('Bearer explicit');
});
it('falls back to authToken when there is no live token to read', async () => {
// Boot-time calls run before `globalThis.puter` is assigned.
const xhrs = installFakeXHR(respond({ body: {} }));
await fetchUrl('https://api.example/whoami', {
includePuterAuth: true,
authToken: 'from-instance',
});
expect(xhrs[0]._reqHeaders['authorization']).toBe('Bearer from-instance');
});
it('prefers the live token over the fallback when both are available', async () => {
globalThis.puter = { authToken: 'live' };
const xhrs = installFakeXHR(respond({ body: {} }));
await fetchUrl('https://api.example/whoami', {
includePuterAuth: true,
authToken: 'from-instance',
});
expect(xhrs[0]._reqHeaders['authorization']).toBe('Bearer live');
});
it('omits the Bearer header when includePuterAuth is false', async () => {
globalThis.puter = { authToken: 'tok-123' };
const xhrs = installFakeXHR(respond({ body: {} }));
@@ -184,6 +212,74 @@ describe('fetchUrl', () => {
expect(triggerReauth).not.toHaveBeenCalled();
expect(resp.ok).toBe(false);
});
// A request the user didn't initiate (boot telemetry, cache warmers)
// must not put a sign-in popup or consent dialog in front of someone who
// only loaded the page.
describe('interactiveReauth: false', () => {
const makePuter = () => ({
authToken: 'stale',
env: 'web',
triggerReauth: vi.fn(),
resetAuthToken: vi.fn(),
dropStaleAuthToken: vi.fn(),
ui: { authenticateWithPuter: vi.fn() },
});
it('drops the stale token without raising UI on reauth_required', async () => {
globalThis.puter = makePuter();
installFakeXHR(respond({
status: 401,
body: { code: 'reauth_required', reason: 'token_v1', auth_id: 'a' },
}));
const resp = await fetchUrl('https://api.example/rao', {
method: 'POST',
includePuterAuth: true,
interactiveReauth: false,
});
expect(globalThis.puter.triggerReauth).not.toHaveBeenCalled();
expect(globalThis.puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
// Reported with the token it was sent with, so a reauth that
// completed meanwhile keeps the token it installed.
expect(globalThis.puter.dropStaleAuthToken).toHaveBeenCalledWith({
reason: 'token_v1',
auth_id: 'a',
sentToken: 'stale',
});
expect(resp.ok).toBe(false);
expect(resp.status).toBe(401);
});
it('drops the stale token without raising UI on token_auth_failed', async () => {
globalThis.puter = makePuter();
installFakeXHR(respond({ status: 401, body: { code: 'token_auth_failed' } }));
const resp = await fetchUrl('https://api.example/whoami', {
includePuterAuth: true,
interactiveReauth: false,
});
expect(globalThis.puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
expect(globalThis.puter.resetAuthToken).not.toHaveBeenCalled();
expect(globalThis.puter.dropStaleAuthToken).toHaveBeenCalledTimes(1);
expect(resp.ok).toBe(false);
});
it('leaves a successful background request alone', async () => {
// The flag only governs the prompt: a 200 is unaffected.
globalThis.puter = makePuter();
installFakeXHR(respond({ status: 200, body: { ok: true } }));
const resp = await fetchUrl('https://api.example/rao', {
method: 'POST',
includePuterAuth: true,
interactiveReauth: false,
});
expect(resp.ok).toBe(true);
expect(globalThis.puter.dropStaleAuthToken).not.toHaveBeenCalled();
});
});
});
describe('API call logging', () => {
+5 -1
View File
@@ -94,7 +94,11 @@ async function handle_resp (success_cb, error_cb, resolve_func, reject_func, res
const resp = await parseResponse(response);
// error - unauthorized
if ( response.status === 401 ) {
const reauth = await resolveReauth(resp);
const spec = response._puterReq;
const reauth = await resolveReauth(resp, {
interactive: spec?.interactiveReauth !== false,
sentToken: spec?._sentAuthToken,
});
if ( reauth?.action === 'replay' ) {
// Replay the original request with the fresh token. If the replay
// can't be scheduled (no captured request, or already retried),
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: boot reauth</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; font-size: 14px; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js boot reauth fixture</h1>
<p id="status">Loading puter.js…</p>
<!-- A call the user asks for, to check prompting still happens. -->
<button id="kv-get" disabled>KV get</button>
<script>
// Point the SDK at the local Puter instance (env=web).
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
// Must be the real api subdomain: the SDK sends credentialed CORS
// requests, and only the api host answers with
// Access-Control-Allow-Credentials.
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const statusEl = document.getElementById('status');
function ready() {
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
document.getElementById('kv-get').disabled = false;
}
function waitForPuter() {
if (window.puter && puter.kv) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
document.getElementById('kv-get').addEventListener('click', () => {
puter.kv.get('boot-reauth-probe').catch(() => {});
});
</script>
</body>
</html>
@@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';
import { FIXTURE_URL } from '../helpers/testApp.js';
const FIXTURE = FIXTURE_URL.replace(
'menubar-contextmenu.html',
'boot-reauth.html',
);
// Any token the backend can't honor. Real visitors arrive with a legacy v1
// token, a revoked session, or an expired one; all three come back as
// `401 reauth_required`, which is the only thing that matters here.
const STALE_TOKEN = 'stale.boot.token';
/**
* A stale token in localStorage used to turn a page load into a sign-in prompt:
* the SDK's own boot requests (`/rao`, the `/whoami` cache) escalated their 401
* through `triggerReauth`, which opens the popup — or, with no user activation
* to open one with, renders the consent dialog. Either way a visitor who did
* nothing but load the page was asked to sign in.
*
* The prompt belongs to calls the user initiates, so both are covered here.
*/
test.describe('a stale token does not raise sign-in UI at load', () => {
const seedStaleToken = (page, key) =>
page.addInitScript(
({ k, v }) => {
try {
localStorage.setItem(k, v);
} catch (e) {
/* nothing to seed into */
}
},
{ k: key, v: STALE_TOKEN },
);
for (const key of ['puter.auth.token', 'puter.auth.token.v2']) {
test(`no popup or dialog on load with a stale ${key}`, async ({
page,
}) => {
const popups = [];
page.on('popup', (p) => popups.push(p.url()));
await seedStaleToken(page, key);
await page.goto(FIXTURE);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
// Boot's own requests have gone out and been answered by now.
await page.waitForFunction(() => !window.puter?.authToken, null, {
timeout: 30_000,
});
// The token is dropped a tick before any UI would be attached, so
// asserting the moment it clears can pass by racing the dialog.
await page.waitForTimeout(2000);
expect(popups).toEqual([]);
await expect(page.locator('puter-dialog')).toHaveCount(0);
// The dead token is dropped rather than left to fail every later
// call, in memory and in storage.
expect(
await page.evaluate((k) => localStorage.getItem(k), key),
).toBeNull();
});
}
test('a call the user makes still prompts', async ({ page }) => {
const popupPromise = page.waitForEvent('popup', { timeout: 30_000 });
await seedStaleToken(page, 'puter.auth.token.v2');
await page.goto(FIXTURE);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
await page.waitForFunction(() => !window.puter?.authToken, null, {
timeout: 30_000,
});
await page.locator('#kv-get').click();
const popup = await popupPromise;
expect(popup.url()).toContain('/action/sign-in');
});
});