mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 23:58:47 +00:00
fix: auth message popups (#3487)
* fix: auth message popups * remove v1 auth
This commit is contained in:
1 parent
6159bc9ae1
commit
c8113d7514
28 files changed
+911
-1630
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 --
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) ----------------------------
|
||||
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user