feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 to… (#3158)

* feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 token migration

PUT-1019 (AUTH-5): full revoke-endpoint coverage
- /logout: soft-revoke web session + its asset cookies via revokeCascade
  (app sessions and access tokens survive)
- POST /auth/revoke-session: cascade per row kind (web/app/access_token/asset)
- POST /auth/revoke-all-sessions: revoke all web rows for user; optional
  include_apps=true nuclear option; gated by userProtected (cookie-only)
- revokeAccessToken: soft-revoke matching access_token row in addition to
  removing access_token_permissions
- All revokes are UPDATE revoked_at = now(); no DELETE statements remain

PUT-1021 (SDK-1): backend POST /auth/migrate-token
- v1 access_token/app -> mint matching-kind v2 token, idempotent on
  (auth_id, kind, token_uid)
- v1 web/session -> 409 { code: "reauth_required" } (interactive relogin only)
- Same-origin / signed-referer hardening; rate-limited per IP and auth_id
- Gated by auth.allow_v1_tokens; emits puter_token_v2 cookie for app-in-browser

DB migrations: mysql_mig_10, sqlite 0053 (sessions.access_token_uid column)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(put-1019): reject self-revoke; enrich list-sessions response

- handleRevokeSession refuses uuid === req.actor.session.uid (use /logout
  instead). The cookie that authenticated the call should never be the
  target of a self-revoke — the response can't write fresh auth state
  and the client ends up with an ambiguous identity. revoke-all-sessions
  still has the explicit include_current opt-in for the nuclear case.

- AuthService.listSessions now joins the apps table for kind='app' rows
  (returning { uid, name, title, icon } so the manage-sessions UI can
  render the authorizing app without a second round trip), surfaces
  kind / expires_at / label / last_ip / created_via, and filters out
  asset rows (per-cookie children of web rows, revoked transitively via
  cascade — surfacing them as standalone entries would be confusing).

- Sort order: current session first, then most-recently-active. UI
  relies on this to anchor "you are here" at the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(put-1019 put-1021): review nits — origin normalization, cookie fallback, types, migration order

B1: createAccessToken.options.expiresIn widened to string | number.
    The impl (#hardExpiryFromExpiresIn) and existing callers/tests use
    jsonwebtoken-style strings ('1h', '30d'); narrowing to number forced
    unsafe casts at every call site. Cast at the single sign() boundary
    where jsonwebtoken's typed template-literal SignOptions clashes
    with the wider runtime contract.

B2: Inline comment on the DELETE in access_token_permissions. The
    AUTH-5 "no DELETE on revoke" rule scoped to the `sessions` table
    (where the cascade graph + audit trail matter). Permissions rows
    are the grant manifest for an active token — once its session is
    soft-revoked they're dead-weight cache entries. A future audit
    requirement would land as a `revoked_at` column on this table,
    not a behavior change in this PR.

B3: handleMigrateToken now sets the puter_token_v2 cookie (with the
    shared sessionCookieFlags + httpOnly) when the migration result
    is kind='app'. The endpoint is already gated on Origin so the
    caller is by definition in a browser; access tokens deliberately
    skip the cookie since they're programmatic.

B4: #isMigrateTokenOriginAllowed normalizes both incoming origin and
    config.origin / allowlist entries (trim + strip trailing slash +
    lowercase) before equality. A misconfigured `config.origin =
    "https://puter.com/"` would otherwise reject every same-origin
    browser call.

B5: Replaced 4x `this.config.cookie_name!` non-null assertions in
    AuthController with `(this.config.cookie_name ?? 'puter_token')`.
    IConfig is `Partial<IConfigOptional>` so cookie_name is undefined
    at runtime in some deployments / test setups; the fallback matches
    the pattern in userProtected / OIDCController / puterSite.

B6: MySQLDatabaseClient sorts migrations numerically by trailing
    integer instead of lexically. Existing files use unpadded names
    (`mysql_mig_<N>.sql`), so plain `.sort()` ran mysql_mig_10 before
    mysql_mig_2 — a future migration that depended on _2..9 running
    first would break. Non-numeric filenames fall through to
    localeCompare for determinism.

B7: Restored the docstring for SessionStore.getOrCreateApp's
    `opts.auth_id` ("Stable per-user identity (survives re-login);
    carried on every v2 JWT so manage-sessions can group by identity")
    — the previous edit truncated it to a fragment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test + feat: backend test coverage for PUT-1019/1021 review fixes + worker session methods

Tests
-----
- compareMigrationFilenames (new) covering B6 numeric-sort: confirms
  mysql_mig_10.sql lands after mysql_mig_9.sql; non-numeric files sort
  after numbered ones; stable for already-ordered input.
- listSessions (AuthService.test.ts): excludes kind="asset" rows;
  enriches with kind/expires_at/last_ip/created_via; joins kind="app"
  rows with the apps table; sorts current first then by last_activity
  desc.
- handleRevokeSession (AuthController.test.ts): refuses self-revoke
  with 400; still allows revoking a sibling session.
- handleMigrateToken (AuthController.test.ts): rejects missing/
  disallowed Origin; tolerates trailing slash and uppercase Origin
  (B4 normalization); returns 409 reauth_required for v1 web tokens;
  does NOT set the cookie for access-token migration; DOES set the
  puter_token_v2 cookie (httpOnly + sessionCookieFlags) for
  app-under-user migration.
- SessionStore tests updated to import APP_WINDOW_SECONDS /
  WEB_WINDOW_SECONDS rather than hardcoded 30/90 day values — the
  windows just got bumped to 1y and the assertions need to follow
  the constant.

Refactor
--------
- MySQLDatabaseClient exports compareMigrationFilenames so the sort
  logic is unit-testable in isolation.

Worker tokens
-------------
- AuthService.createWorkerSessionToken(user, meta?): mints a new
  kind="web" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns { session, token, gui_token }
  with worker: true on each JWT.
- AuthService.createWorkerAppToken(actor, appUid): mints a new
  kind="app" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns an app-under-user JWT with
  worker: true. Note the existing idx_sessions_user_app_active
  unique index will collide with an existing non-worker app
  session for the same (user, app) — future schema work can
  carve workers out of that uniqueness.

SessionStore.js: WEB/APP_WINDOW_SECONDS now 1y;
WORKER_WINDOW_SECONDS = 99y added for the worker path.

Full backend suite: 2172 passed / 16 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Salazar
2026-05-26 18:49:36 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 3ae076b73e
commit ac5eecb7f3
20 changed files with 1844 additions and 147 deletions
+67 -41
View File
@@ -5984,19 +5984,18 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
@@ -6006,9 +6005,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -7436,6 +7435,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/memcached": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz",
@@ -7459,6 +7469,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mysql": {
"version": "2.15.26",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
@@ -7570,6 +7587,13 @@
"@types/node": "*"
}
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -8934,9 +8958,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10156,9 +10180,9 @@
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
@@ -10170,7 +10194,7 @@
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
"ws": "~8.20.1"
},
"engines": {
"node": ">=10.2.0"
@@ -10225,9 +10249,9 @@
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -15400,24 +15424,24 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
"integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
@@ -15481,9 +15505,9 @@
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -16610,13 +16634,13 @@
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
"ws": "~8.20.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
@@ -16637,9 +16661,9 @@
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -18182,9 +18206,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -18401,7 +18425,9 @@
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
"nodemon": "^3.1.0",
"typescript": "^5.9.3",
@@ -0,0 +1,95 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { compareMigrationFilenames } from './MySQLDatabaseClient.js';
describe('compareMigrationFilenames', () => {
it('orders numbered migrations numerically, not lexically', () => {
const input = [
'mysql_mig_10.sql',
'mysql_mig_2.sql',
'mysql_mig_1.sql',
'mysql_mig_9.sql',
'mysql_mig_3.sql',
];
const sorted = [...input].sort(compareMigrationFilenames);
expect(sorted).toEqual([
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_3.sql',
'mysql_mig_9.sql',
'mysql_mig_10.sql',
]);
});
it('keeps mig_10 after mig_9 when the real prod set is shuffled', () => {
// Reflects the current migrations/mysql/ listing — guards against
// a future "let's just rename to padded" suggestion accidentally
// re-introducing the lex bug if the rename is incomplete.
const real = [
'mysql_mig_9.sql',
'mysql_mig_3.sql',
'mysql_mig_10.sql',
'mysql_mig_1.sql',
'mysql_mig_7.sql',
'mysql_mig_5.sql',
'mysql_mig_2.sql',
'mysql_mig_4.sql',
'mysql_mig_8.sql',
'mysql_mig_6.sql',
];
const sorted = [...real].sort(compareMigrationFilenames);
for (let i = 1; i <= sorted.length; i += 1) {
expect(sorted[i - 1]).toBe(`mysql_mig_${i}.sql`);
}
});
it('sorts non-numeric filenames after numbered ones, lexically among themselves', () => {
// Numbered files always run first (they're the canonical history);
// unmatched names follow in localeCompare order. Mixing the two
// sets prevents a vendor dump from accidentally wedging itself
// between mig_4 and mig_5 if it happened to lex-sort there.
const mixed = [
'mysql_mig_2.sql',
'mysql_vendor_dump.sql',
'mysql_mig_10.sql',
'mysql_bootstrap.sql',
'mysql_mig_1.sql',
];
const sorted = [...mixed].sort(compareMigrationFilenames);
expect(sorted).toEqual([
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_10.sql',
'mysql_bootstrap.sql',
'mysql_vendor_dump.sql',
]);
});
it('is stable for already-sorted input', () => {
const sorted = [
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_10.sql',
'mysql_mig_11.sql',
];
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
});
});
@@ -44,6 +44,37 @@ const RETRIABLE_ERROR_MESSAGES = [
'ETIMEDOUT',
];
/**
* Comparator for MySQL migration filenames.
*
* Existing files are named `mysql_mig_<N>.sql` with unpadded N, so a
* plain lexical sort puts `mysql_mig_10.sql` BEFORE `mysql_mig_2.sql`.
* Pull the trailing integer out and sort numerically. Anything that
* doesn't match the `_<digits>.sql` shape (one-off, vendor dump) falls
* back to lexical comparison so the order stays deterministic, and
* unmatched names sort *after* numbered files so future numbered
* migrations don't get interleaved into a one-off's namespace.
*
* Exported only for the unit test — the production caller is the
* `runMigrations()` loop inside this file.
*/
export const compareMigrationFilenames = (a: string, b: string): number => {
const numericIndex = (name: string): number => {
const m = /_(\d+)\.sql$/.exec(name);
return m ? Number.parseInt(m[1], 10) : Number.NaN;
};
const na = numericIndex(a);
const nb = numericIndex(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
if (na !== nb) return na - nb;
} else if (Number.isFinite(na)) {
return -1;
} else if (Number.isFinite(nb)) {
return 1;
}
return a.localeCompare(b);
};
type PoolConfig = Parameters<typeof createPool>[0];
enum Configuration {
@@ -247,7 +278,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
.filter(
(f) => f.endsWith('.sql') && f.startsWith('mysql'),
)
.sort();
.sort(compareMigrationFilenames);
} catch (e) {
throw new Error(
`[mysql] migration path is unreadable: ${dir}`,
@@ -81,6 +81,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[46, ['0050_add_preamble_version.sql']],
[47, ['0051_sessions_v2.sql']],
[48, ['0052_sessions_v2_lookups.sql']],
[49, ['0053_sessions_access_token_uid.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,53 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- AUTH-5 (PUT-1019) — mirrors SQLite migration 0053. Adds the
-- `access_token_uid` reverse-lookup column on `sessions` so raw-uuid
-- revoke can find the matching session row when only the v2 token_uid
-- (no JWT) is presented.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
-- directory can be replayed safely.
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
DELIMITER //
CREATE PROCEDURE _puter_sessions_access_token_uid()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'access_token_uid'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `access_token_uid` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_access_token_uid'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_access_token_uid` (`access_token_uid`);
END IF;
END//
DELIMITER ;
CALL _puter_sessions_access_token_uid();
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
@@ -0,0 +1,29 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- AUTH-5 (PUT-1019) — let `kind='access_token'` rows be reverse-looked-up
-- from the `token_uid` claim that lives only in `access_token_permissions`.
-- Required so `POST /auth/revoke-access-token` with a raw token_uid input
-- (no JWT) can find and soft-revoke the session row, matching the JWT
-- input path's coverage. Without it, raw-uuid revoke would only drop the
-- permissions row, leaving the session-row kill switch un-flipped.
ALTER TABLE `sessions` ADD COLUMN `access_token_uid` TEXT;
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
@@ -30,6 +30,7 @@
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { EventClient } from '../../clients/event/EventClient.js';
@@ -3405,4 +3406,259 @@ describe('AuthController.handleRevokeSession additional branches', () => {
const body = res.body as { sessions: unknown[] };
expect(Array.isArray(body.sessions)).toBe(true);
});
it('refuses to revoke the callers OWN current session row (400)', async () => {
// PUT-1019 invariant: a self-revoke leaves the client in an
// ambiguous identity state because the response can't write
// fresh auth state. /logout is the only path that should end
// the session you're currently authenticated under.
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const actorWithSession = {
...actor,
session: { uid: sessionUid },
} as Actor;
await expect(
controller.handleRevokeSession(
makeReq({ uuid: sessionUid }, { actor: actorWithSession }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
});
it('still allows revoking a DIFFERENT session belonging to the same user', async () => {
// Sanity check that the self-revoke guard only blocks the
// caller's own uuid — sibling rows must still be revokable
// (that's the whole point of manage-sessions).
const { user, actor } = await makeUserAndActor();
const callerSession = await server.services.auth.createSessionToken(
user,
{},
);
const targetSession = await server.services.auth.createSessionToken(
user,
{},
);
const actorWithSession = {
...actor,
session: {
uid: (callerSession.session as { uuid: string }).uuid,
},
} as Actor;
const res = makeRes();
await controller.handleRevokeSession(
makeReq(
{ uuid: (targetSession.session as { uuid: string }).uuid },
{ actor: actorWithSession },
),
res,
);
expect((res.body as { sessions: unknown[] }).sessions).toBeDefined();
});
});
// ── handleMigrateToken (PUT-1021 SDK-1) ─────────────────────────────
describe('AuthController.handleMigrateToken', () => {
const TEST_ORIGIN = 'https://migrate.test.local';
// PuterServer keeps config in a private field (#config), so we go
// through the controller — IController stores it as `protected
// config` which TS marks but JS doesn't enforce, and the controller
// is the actual consumer of #isMigrateTokenOriginAllowed anyway.
const controllerConfig = () =>
(controller as { config: Record<string, unknown> }).config;
// Mints a v1-shaped JWT signed under the test server's legacy
// secret. The body matches what migrateLegacyToken expects per
// `decoded.type`.
const mintV1Token = (payload: Record<string, unknown>): string => {
const legacy = controllerConfig().jwt_secret as string | undefined;
if (!legacy) throw new Error('test config missing jwt_secret');
return jwt.sign(payload, legacy);
};
beforeAll(() => {
// Make the origin allow-check pass for these tests. We mutate
// the live config because setupTestServer is shared across the
// file; the original value is undefined (default config has no
// `origin`) so we don't need to restore.
controllerConfig().origin = TEST_ORIGIN;
});
it('rejects when the Origin header is missing', async () => {
await expect(
controller.handleMigrateToken(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rejects when the Origin header is not in config.origin or the allowlist', async () => {
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
await expect(
controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://not-allowed.example',
authorization: `Bearer ${v1}`,
},
},
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('normalizes trailing slash on the request Origin (B4)', async () => {
// The Origin header per spec doesn't carry a trailing slash, but
// a misconfigured proxy or a deployment with config.origin
// ending in `/` would otherwise force every call to reject.
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: `${TEST_ORIGIN}/`, // trailing slash
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
});
it('normalizes case on the request Origin (B4)', async () => {
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN.toUpperCase(),
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
});
it('returns 409 reauth_required for v1 web/session tokens', async () => {
// Web tokens never migrate silently — they always go through the
// interactive reauth flow (PUT-1023). The body code is what
// puter.js / GUI key on; the 409 status is what tells SDK code
// "this isn't a generic auth failure, route through reauth".
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'session',
user_uid: user.uuid,
uuid: uuidv4(),
});
await expect(
controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 409,
code: 'reauth_required',
});
});
it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => {
// Access tokens are programmatic — they ride in Authorization
// headers, not browser cookies. Setting a cookie here would
// confuse cookie-only middleware downstream.
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect(res.cookies.puter_token_v2).toBeUndefined();
expect((res.body as { kind: string }).kind).toBe('access_token');
expect((res.body as { token: string }).token).toBeTruthy();
});
it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => {
// App tokens DO get a cookie companion — the app runs inside an
// iframe in the GUI, and the GUI's cookie-only middleware
// authenticates subsequent calls from the iframe via the cookie
// rather than the client having to plumb Authorization through
// every request.
const { user } = await makeUserAndActor();
const appUid = `app-${uuidv4()}`;
const v1 = mintV1Token({
type: 'app-under-user',
user_uid: user.uuid,
app_uid: appUid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('app');
const cookie = res.cookies.puter_token_v2;
expect(cookie).toBeDefined();
expect(cookie.value).toBe((res.body as { token: string }).token);
expect(cookie.opts?.httpOnly).toBe(true);
});
});
+193 -24
View File
@@ -26,7 +26,10 @@ import { Controller, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
import { createUserProtectedGate } from '../../core/http/middleware/userProtected.js';
import {
createSessionCookieGate,
createUserProtectedGate,
} from '../../core/http/middleware/userProtected.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import {
ROUTES_METADATA_KEY,
@@ -158,7 +161,10 @@ export class AuthController extends PuterController {
}
// Verify password
const passwordMatch = await bcrypt.compare(password, user.password);
const passwordMatch = await bcrypt.compare(
password,
user.password as string,
);
if (!passwordMatch) {
throw new HttpError(401, 'Incorrect password.', {
legacyCode: 'password_mismatch',
@@ -210,7 +216,10 @@ export class AuthController extends PuterController {
let decoded;
try {
decoded = this.services.token.verify('otp', token);
decoded = this.services.token.verify<{
user_uid: string;
purpose: string;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
legacyCode: 'bad_request',
@@ -264,7 +273,10 @@ export class AuthController extends PuterController {
let decoded;
try {
decoded = this.services.token.verify('otp', token);
decoded = this.services.token.verify<{
user_uid: string;
purpose: string;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
legacyCode: 'bad_request',
@@ -288,7 +300,7 @@ export class AuthController extends PuterController {
}
const hashed = hashRecoveryCode(code);
const codes = (user.otp_recovery_codes || '')
const codes = ((user.otp_recovery_codes as string) || '')
.split(',')
.filter(Boolean);
const idx = codes.indexOf(hashed);
@@ -700,7 +712,7 @@ export class AuthController extends PuterController {
})
async handleLogout(req: Request, res: Response): Promise<void> {
// Clear the session cookie
res.clearCookie(this.config.cookie_name!);
res.clearCookie(this.config.cookie_name ?? 'puter_token');
// Remove the session (fire-and-forget)
if (req.token) {
@@ -711,7 +723,9 @@ export class AuthController extends PuterController {
// same path as /user-protected/delete-own-user — so we don't
// orphan fsentries/sessions/permissions.
if (req.actor?.user && !req.actor.user.email) {
const user = await this.stores.user.getByUuid(req.actor.user.uuid);
const user = await this.stores.user.getByUuid(
req.actor.user.uuid as string,
);
if (user && user.password === null && user.email === null) {
this.#cascadeDeleteUser(user.id).catch((e) => {
console.warn('[logout] temp-user cleanup failed:', e);
@@ -944,7 +958,12 @@ export class AuthController extends PuterController {
let decoded;
try {
decoded = this.services.token.verify('otp', token);
decoded = this.services.token.verify<{
user_uid: string;
email: string;
exp: number;
purpose: string;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid or expired token.', {
legacyCode: 'token_expired' as never,
@@ -956,7 +975,7 @@ export class AuthController extends PuterController {
});
}
const user = await this.stores.user.getByUuid(decoded.user_uid);
const user = await this.stores.user.getByUuid(decoded?.user_uid);
if (!user || user.email !== decoded.email) {
throw new HttpError(400, 'Token is no longer valid.', {
legacyCode: 'bad_request',
@@ -968,7 +987,7 @@ export class AuthController extends PuterController {
});
}
const exp = decoded.exp;
const exp = decoded.exp as number;
const time_remaining = exp
? Math.max(0, exp - Math.floor(Date.now() / 1000))
: 0;
@@ -1001,7 +1020,12 @@ export class AuthController extends PuterController {
let decoded;
try {
decoded = this.services.token.verify('otp', token);
decoded = this.services.token.verify<{
user_uid: string;
email: string;
token: string;
purpose: string;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid or expired token.', {
legacyCode: 'token_expired' as never,
@@ -1741,12 +1765,10 @@ export class AuthController extends PuterController {
res.json(sessions);
}
@Post('/auth/revoke-session', {
subdomain: 'api',
requireUserActor: true,
allowUnconfirmed: true,
antiCsrf: true,
})
// Wired imperatively in `registerRoutes` so the cookie-only gate
// (built from `this.config`) can be composed in. Cookie-only is
// mandatory: an access token must not be able to revoke its own
// issuing web session.
async handleRevokeSession(req: Request, res: Response): Promise<void> {
const { uuid } = req.body;
if (!uuid || typeof uuid !== 'string') {
@@ -1754,6 +1776,18 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
// The caller's own session row must go through /logout, not a
// self-revoke — otherwise the response can't write fresh auth
// state and the client ends up with an ambiguous post-revoke
// identity. /auth/revoke-all-sessions still supports a separate
// `include_current` opt-in for the nuclear case.
if (uuid === req.actor!.session?.uid) {
throw new HttpError(
400,
'Cannot revoke your current session — use /logout instead',
{ legacyCode: 'bad_request' },
);
}
const session = await this.stores.session.getByUuid(uuid);
if (session.user_id !== req.actor!.user.id) {
throw new HttpError(403, 'Can only revoke your own sessions', {
@@ -1765,6 +1799,16 @@ export class AuthController extends PuterController {
res.json({ sessions });
}
async handleRevokeAllSessions(req: Request, res: Response): Promise<void> {
const { include_current, include_apps } = req.body ?? {};
await this.services.auth.revokeAllSessions(req.actor!, {
includeCurrent: !!include_current,
includeApps: !!include_apps,
});
const sessions = await this.services.auth.listSessions(req.actor!);
res.json({ sessions });
}
// ── Dev app permissions ─────────────────────────────────────────
@Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true })
@@ -2001,6 +2045,74 @@ export class AuthController extends PuterController {
// ── Access tokens ───────────────────────────────────────────────
async handleMigrateToken(req: Request, res: Response): Promise<void> {
// 1. Origin lock. Reject anything that isn't same-origin to
// `config.origin` or in the explicit per-deployment allowlist.
// No Origin header → reject (this endpoint is browser-only by
// design; server-side callers should re-auth properly).
const reqOrigin = req.headers.origin;
if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) {
throw new HttpError(403, 'Origin not allowed', {
legacyCode: 'forbidden',
});
}
// 2. Extract token. Header takes precedence so body-capture logs
// (if any) never see the credential.
const authHeader = req.headers.authorization;
const headerToken =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length).trim()
: null;
const bodyToken =
typeof req.body?.token === 'string' ? req.body.token.trim() : null;
const v1Token = headerToken || bodyToken;
if (!v1Token) {
throw new HttpError(400, 'Missing token', {
legacyCode: 'bad_request',
});
}
const result = await this.services.auth.migrateLegacyToken(v1Token, {
ip: req.ip,
userAgent:
typeof req.headers['user-agent'] === 'string'
? req.headers['user-agent']
: undefined,
});
// `puter_token_v2` is the cookie companion to v2 app tokens —
// the app runs in-browser (we already gated on Origin above) so
// the GUI's cookie-only middleware can authenticate subsequent
// calls from the same iframe without the client having to
// forward Authorization headers. Access tokens are programmatic
// (no browser cookie surface) so we deliberately skip them here.
if (result.kind === 'app') {
res.cookie('puter_token_v2', result.token, {
...sessionCookieFlags(this.config),
httpOnly: true,
});
}
res.json(result);
}
#isMigrateTokenOriginAllowed(origin: string): boolean {
// Origin headers and config values both reach us in inconsistent
// shapes (trailing slash, mixed case from misconfigured deploys,
// stray whitespace from JSON config edits). Normalize both sides
// before equality so a `config.origin` with a trailing slash
// doesn't reject every same-origin browser call.
const normalize = (raw: string | undefined): string =>
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
const incoming = normalize(origin);
if (!incoming) return false;
if (incoming === normalize(this.config.origin)) return true;
const allowlist = (
this.config as { allow_migrate_token_origins?: string[] }
).allow_migrate_token_origins;
if (!Array.isArray(allowlist)) return false;
return allowlist.some((entry) => normalize(entry) === incoming);
}
@Post('/auth/create-access-token', {
subdomain: 'api',
requireAuth: true,
@@ -2032,10 +2144,10 @@ export class AuthController extends PuterController {
res.json({ token });
}
@Post('/auth/revoke-access-token', {
subdomain: 'api',
requireUserActor: true,
})
// Wired imperatively in `registerRoutes` so the cookie-only gate
// (built from `this.config`) can be composed in. Cookie-only is
// mandatory: a leaked access token must not be able to silently
// revoke its own siblings.
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
let { tokenOrUuid } = req.body;
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
@@ -2362,7 +2474,7 @@ export class AuthController extends PuterController {
user,
req.actor.session.uid,
);
res.cookie(this.config.cookie_name, sessionToken, {
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
...sessionCookieFlags(this.config),
httpOnly: true,
});
@@ -2378,7 +2490,7 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
res.clearCookie(this.config.cookie_name!);
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_revalidation');
await this.#cascadeDeleteUser(userId);
res.json({ success: true });
@@ -2525,6 +2637,63 @@ export class AuthController extends PuterController {
},
(req, res) => this.handleDeleteOwnUser(req, res),
);
const sessionCookieGate = createSessionCookieGate(this.config);
router.post(
'/auth/revoke-session',
{
subdomain: 'api',
requireUserActor: true,
allowUnconfirmed: true,
antiCsrf: true,
middleware: [sessionCookieGate],
},
(req, res) => this.handleRevokeSession(req, res),
);
router.post(
'/auth/revoke-all-sessions',
{
subdomain: 'api',
requireUserActor: true,
allowUnconfirmed: true,
antiCsrf: true,
rateLimit: {
scope: 'revoke-all-sessions',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
middleware: [sessionCookieGate],
},
(req, res) => this.handleRevokeAllSessions(req, res),
);
router.post(
'/auth/revoke-access-token',
{
subdomain: 'api',
requireUserActor: true,
antiCsrf: true,
middleware: [sessionCookieGate],
},
(req, res) => this.handleRevokeAccessToken(req, res),
);
router.post(
'/auth/migrate-token',
{
subdomain: 'api',
rateLimit: {
scope: 'migrate-token',
limit: 20,
window: 15 * 60_000,
key: 'ip',
},
},
(req, res) => this.handleMigrateToken(req, res),
);
}
// ── Private helpers ──────────────────────────────────────────────
@@ -2636,7 +2805,7 @@ export class AuthController extends PuterController {
await this.services.auth.createSessionToken(user as never, meta);
// HTTP-only cookie gets the session token
res.cookie(this.config.cookie_name, sessionToken, {
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
...sessionCookieFlags(this.config),
httpOnly: true,
});
@@ -35,7 +35,6 @@ interface AuthProbeOptions {
kvStore?: SystemKVStore;
}
/** Day-bucketed KV key for AUTH-4 metrics. */
const authV2MetricsKey = (): string => {
const now = new Date();
const yyyy = now.getUTCFullYear();
@@ -492,6 +492,29 @@ 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.
*/
export async function checkRateLimit(key, limit, windowMs, backend) {
const bk = resolveBackend(backend);
try {
return await bk.rate(key, limit, windowMs);
} catch (err) {
console.error(
'[rate-limit] imperative check failed, failing open:',
err,
);
return true;
}
}
// ── Subscription-aware limit resolution ─────────────────────────────
/**
@@ -63,6 +63,30 @@ export interface UserProtectedGateDeps {
tokenService: TokenService;
}
/**
* Cookie-only credential check. Rejects API tokens, GUI tokens,
* `x-api-key` headers, and query-string tokens only a request whose
* resolved `req.token` matches the session cookie passes. Used both by
* `createUserProtectedGate` (as its first stage) and standalone by
* routes that need session-cookie-only credentials without the full
* password-revalidation gate (e.g. `/auth/revoke-*`, where an access
* token shouldn't be able to revoke its own issuing web session).
*/
export const createSessionCookieGate = (config: IConfig): RequestHandler => {
const cookieName = config.cookie_name ?? 'puter_token';
return (req, _res, next) => {
const cookieValue = req.cookies?.[cookieName];
if (!cookieValue || (req.token && req.token !== cookieValue)) {
return next(
new HttpError(401, 'Session cookie required', {
legacyCode: 'session_required',
}),
);
}
next();
};
};
export interface UserProtectedGateOptions {
/** Allow temp accounts (no password + no email) through. Default: false. */
allowTempUsers?: boolean;
@@ -95,19 +119,10 @@ export const createUserProtectedGate = (
options: UserProtectedGateOptions = {},
): RequestHandler[] => {
const { config, userStore, oidcService, tokenService } = deps;
const cookieName = config.cookie_name ?? 'puter_token';
const allowTemp = !!options.allowTempUsers;
// 1. Session cookie only.
const requireSessionCookie: RequestHandler = (req, _res, next) => {
const cookieValue = req.cookies?.[cookieName];
if (!cookieValue || (req.token && req.token !== cookieValue)) {
throw new HttpError(401, 'Session cookie required', {
legacyCode: 'session_required',
});
}
next();
};
// 1. Session cookie only. Shared with the standalone cookie-only gate.
const requireSessionCookie = createSessionCookieGate(config);
// 2. Fresh user row (bypass cache to catch just-suspended accounts).
// `getById` doesn't take options; go through `getByProperty` with
+2
View File
@@ -75,7 +75,9 @@
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
"nodemon": "^3.1.0",
"typescript": "^5.9.3",
@@ -517,6 +517,241 @@ describe('AuthService (integration)', () => {
await server.stores.session.getByUuid(sessionUuid),
).toBeFalsy();
});
it('listSessions excludes kind="asset" rows', async () => {
// Asset rows are per-cookie children of `web` rows, revoked
// transitively via the cascade — surfacing them in the
// manage-sessions UI as standalone entries would be confusing.
const user = await makeUser();
const { session: webSession } =
await authService.createSessionToken(user, {});
const webUuid = (webSession as { uuid: string }).uuid;
const assetRow = await server.stores.session.create(user.id, {
kind: 'asset',
parent_session_id: webUuid,
});
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: webUuid },
} as unknown as Actor;
const rows = await authService.listSessions(actor);
expect(
rows.find(
(r) =>
(r as { uuid: string }).uuid ===
(assetRow as { uuid: string }).uuid,
),
).toBeUndefined();
expect(
rows.find((r) => (r as { uuid: string }).uuid === webUuid),
).toBeTruthy();
});
it('listSessions enriches rows with kind / expires_at / last_ip / created_via', async () => {
// Manage-sessions GUI keys on these fields to render the rich
// row layout (kind badge, IP, expires-in). Lock the shape so
// future GUI work can rely on them.
const user = await makeUser();
const { session } = await authService.createSessionToken(user, {
user_agent: 'shape-probe',
ip: '203.0.113.7',
});
const sessionUuid = (session as { uuid: string }).uuid;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: sessionUuid },
} as unknown as Actor;
const rows = await authService.listSessions(actor);
const row = rows.find(
(r) => (r as { uuid: string }).uuid === sessionUuid,
) as Record<string, unknown> | undefined;
expect(row).toBeTruthy();
expect(row!.kind).toBe('web');
expect(typeof row!.created_at).toBe('number');
expect(typeof row!.last_activity).toBe('number');
expect(row!.expires_at).toEqual(expect.any(Number));
expect(row!.last_ip).toBe('203.0.113.7');
// app_uid / app are null for web rows; present for app rows.
expect(row!.app_uid).toBeNull();
expect(row!.app).toBeNull();
});
it('listSessions joins kind="app" rows with the apps table', async () => {
// App rows carry an `app_uid`; AuthService.listSessions does a
// batch lookup against the apps table so the GUI doesn't need a
// second round trip. If the app row exists, the response
// includes a non-null `app: { uid, name, title, icon }`.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `icon`, `description`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
appUid,
`app_name_${Math.random().toString(36).slice(2, 10)}`,
'Listed App Title',
'data:image/png;base64,ICON',
'',
`https://${Math.random().toString(36).slice(2, 10)}.example`,
user.id ?? null,
],
);
await server.stores.session.create(user.id, {
kind: 'app',
app_uid: appUid,
});
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as unknown as Actor;
const rows = await authService.listSessions(actor);
const appRow = rows.find(
(r) => (r as { kind?: string }).kind === 'app',
) as Record<string, unknown> | undefined;
expect(appRow).toBeTruthy();
expect(appRow!.app_uid).toBe(appUid);
const app = appRow!.app as { title: string; icon: string };
expect(app.title).toBe('Listed App Title');
expect(app.icon).toBe('data:image/png;base64,ICON');
});
it('listSessions sorts the actors current session first, then by last_activity desc', async () => {
// Manage-sessions GUI anchors "you are here" at the top of the
// list; downstream rendering doesn't re-sort, so the backend
// order is what users see.
const user = await makeUser();
const { session: olderSession } =
await authService.createSessionToken(user, {});
const { session: newerSession } =
await authService.createSessionToken(user, {});
const { session: currentSession } =
await authService.createSessionToken(user, {});
const olderUuid = (olderSession as { uuid: string }).uuid;
const newerUuid = (newerSession as { uuid: string }).uuid;
const currentUuid = (currentSession as { uuid: string }).uuid;
// Bump `last_activity` to FUTURE values — updateActivity has
// a `last_activity < ?` guard that skips no-op updates, so
// any past timestamp gets silently dropped after the fresh
// rows created above stamped `last_activity = now`.
const future = Math.floor(Date.now() / 1000) + 60_000;
await server.stores.session.updateActivity(olderUuid, future);
await server.stores.session.updateActivity(newerUuid, future + 1000);
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: currentUuid },
} as unknown as Actor;
const rows = await authService.listSessions(actor);
const ourRows = rows.filter((r) =>
[olderUuid, newerUuid, currentUuid].includes(
(r as { uuid: string }).uuid,
),
);
expect(
(ourRows[0] as { uuid: string; current: boolean }).uuid,
).toBe(currentUuid);
expect((ourRows[0] as { current: boolean }).current).toBe(true);
// Newer non-current row comes before the older one.
const newerIdx = ourRows.findIndex(
(r) => (r as { uuid: string }).uuid === newerUuid,
);
const olderIdx = ourRows.findIndex(
(r) => (r as { uuid: string }).uuid === olderUuid,
);
expect(newerIdx).toBeLessThan(olderIdx);
});
});
describe('createWorkerSessionToken / createWorkerAppToken', () => {
// The test config's v2 jwt_secret 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> => {
return server.services.token.verify('auth', token) as Record<
string,
unknown
>;
};
it('createWorkerSessionToken mints a kind="web" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => {
const user = await makeUser();
const before = Math.floor(Date.now() / 1000);
const { session, token, gui_token } =
await authService.createWorkerSessionToken(user, {
user_agent: 'worker-agent',
});
const row = (await server.stores.session.getByUuid(
(session as { uuid: string }).uuid,
)) as Record<string, unknown>;
expect(row.kind).toBe('web');
// expires_at lands in the ~99-year window — assert lower
// bound only so the test isn't fragile to small drift or a
// future constant adjustment.
expect(row.expires_at as number).toBeGreaterThanOrEqual(
before + 50 * 365 * 24 * 60 * 60,
);
const meta =
typeof row.meta === 'string'
? (JSON.parse(row.meta as string) as Record<
string,
unknown
>)
: (row.meta as Record<string, unknown>);
expect(meta.worker).toBe(true);
// Both JWTs carry the worker claim so downstream code can
// distinguish without re-reading the session row.
expect(decodeAuth(token).worker).toBe(true);
expect(decodeAuth(gui_token).worker).toBe(true);
});
it('createWorkerAppToken mints a kind="app" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => {
const user = await makeUser();
// Existing AuthService surfaces (e.g. getUserAppToken in the
// tests below) shape app_uid as `app-${uuid}` — keep the
// same shape here so any downstream validator that asserts
// on the hyphenated form doesn't reject the row.
const appUid = `app-${uuidv4()}`;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const before = Math.floor(Date.now() / 1000);
const token = await authService.createWorkerAppToken(
actor,
appUid,
);
const decoded = decodeAuth(token);
expect(decoded.type).toBe('app-under-user');
expect(decoded.worker).toBe(true);
expect(decoded.app_uid).toBe(appUid);
expect(decoded.user_uid).toBe(user.uuid);
const sessionUid = decoded.session_uid as string;
const row = (await server.stores.session.getByUuid(
sessionUid,
)) as Record<string, unknown>;
expect(row.kind).toBe('app');
expect(row.app_uid).toBe(appUid);
expect(row.expires_at as number).toBeGreaterThanOrEqual(
before + 50 * 365 * 24 * 60 * 60,
);
const meta =
typeof row.meta === 'string'
? (JSON.parse(row.meta as string) as Record<
string,
unknown
>)
: (row.meta as Record<string, unknown>);
expect(meta.worker).toBe(true);
});
it('createWorkerAppToken refuses an actor with no user (403)', async () => {
await expect(
authService.createWorkerAppToken(
{ user: undefined } as unknown as Actor,
'app-x',
),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe('appUidFromOrigin', () => {
@@ -789,4 +1024,357 @@ describe('AuthService (integration)', () => {
).toThrow();
});
});
// ── AUTH-5 (PUT-1019) revoke coverage ────────────────────────────
describe('revokeAccessToken raw-uuid session-row coverage', () => {
// The JWT-input branch has always flipped the session row's
// revoked_at. AUTH-5 closes the raw-uuid gap: the new
// `sessions.access_token_uid` column lets revoke find the row
// for v2-minted tokens even when no JWT was presented.
it('soft-revokes the v2 session row when revoked by raw token_uid', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
['service:foo:ii:read'],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
session_uid: string;
};
// Confirm session row is active before revoke.
const before = await server.stores.session.getByUuid(
decoded.session_uid,
);
expect(before).toBeTruthy();
await authService.revokeAccessToken(actor, decoded.token_uid);
// Row is soft-revoked, not just permissions-stripped.
const after = await server.stores.session.getByUuid(
decoded.session_uid,
);
expect(after).toBeNull();
});
});
describe('revokeAllSessions', () => {
it('throws 403 when actor has no user', async () => {
await expect(
authService.revokeAllSessions({
user: undefined,
} as unknown as Actor),
).rejects.toMatchObject({ statusCode: 403 });
});
it('revokes every web session except the caller by default', async () => {
const user = await makeUser();
const otherDevice = await authService.createSessionToken(user, {});
const otherUuid = (otherDevice.session as { uuid: string }).uuid;
const currentDevice = await authService.createSessionToken(user, {});
const currentUuid = (currentDevice.session as { uuid: string })
.uuid;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: currentUuid },
} as unknown as Actor;
await authService.revokeAllSessions(actor);
// Caller's session survives.
expect(
await server.stores.session.getByUuid(currentUuid),
).toBeTruthy();
// Other device's session is gone.
expect(
await server.stores.session.getByUuid(otherUuid),
).toBeNull();
});
it('with includeCurrent=true also revokes the caller', async () => {
const user = await makeUser();
const currentDevice = await authService.createSessionToken(user, {});
const currentUuid = (currentDevice.session as { uuid: string })
.uuid;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: currentUuid },
} as unknown as Actor;
await authService.revokeAllSessions(actor, {
includeCurrent: true,
});
expect(
await server.stores.session.getByUuid(currentUuid),
).toBeNull();
});
it('leaves app authorizations alone by default', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const appUid = `app-${uuidv4()}`;
// Mint an app authorization (creates a kind='app' session row).
await authService.getUserAppToken(actor, appUid);
// Plus a web session that revoke-all should touch.
const web = await authService.createSessionToken(user, {});
const webUuid = (web.session as { uuid: string }).uuid;
await authService.revokeAllSessions({
user: actor.user,
session: { uid: 'unrelated' },
} as unknown as Actor);
// Web is gone, app survives.
expect(
await server.stores.session.getByUuid(webUuid),
).toBeNull();
const appSession = await server.stores.session.getOrCreateApp(
user.id,
appUid,
);
expect(appSession?.revoked_at ?? null).toBeNull();
});
it('with includeApps=true also revokes app authorizations', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const appUid = `app-${uuidv4()}`;
const appToken = await authService.getUserAppToken(actor, appUid);
const appDecoded = server.services.token.verify('auth', appToken) as {
session_uid: string;
};
await authService.revokeAllSessions(
{
user: actor.user,
session: { uid: 'unrelated' },
} as unknown as Actor,
{ includeApps: true },
);
expect(
await server.stores.session.getByUuid(appDecoded.session_uid),
).toBeNull();
});
});
// ── SDK-1 (PUT-1021) 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,
[['service:foo:ii: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();
}
});
});
});
+417 -36
View File
@@ -20,9 +20,11 @@
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,
WORKER_WINDOW_SECONDS,
} from '../../stores/session/SessionStore.js';
import type { UserRow } from '../../stores/user/UserStore';
import type { LayerInstances } from '../../types';
@@ -196,8 +198,9 @@ export class AuthService extends PuterService {
user: UserRow,
sessionUuid: string,
authId: string,
opts: { worker?: boolean } = {},
): string {
return this.services.token.sign('auth', {
const claims: Record<string, unknown> = {
type,
version: '2',
// `uuid` retained alongside `session_uid` so any legacy reader
@@ -207,19 +210,96 @@ export class AuthService extends PuterService {
session_uid: sessionUuid,
user_uid: user.uuid,
auth_id: authId,
});
};
if (opts.worker) claims.worker = true;
return this.services.token.sign('auth', claims);
}
/**
* Stable per-user identity carried on every v2 token (PUT-1010). Survives
* re-login so the login endpoint can re-attach a new session to the same
* underlying account critical for temp users whose files are keyed off
* the account that owns them.
*
* For normal users this is `user.uuid` (already stable). Temp-user
* dedicated ids are PUT-1016's territory; until then, the uuid is fine
* because temp re-login swaps the row but keeps the uuid.
* Worker variant of `createSessionToken`. Mints a new `kind='web'`
* session row tagged `meta.worker = true` and expiring after
* `WORKER_WINDOW_SECONDS` (vs. WEB_WINDOW_SECONDS for an interactive
* session). The emitted JWT carries `worker: true` so downstream
* code can tell a worker session from a user-driven one without a
* DB round-trip. Same return shape as createSessionToken.
*/
async createWorkerSessionToken(
user: UserRow,
meta: Record<string, unknown> = {},
): Promise<{
session: Record<string, unknown>;
token: string;
gui_token: string;
}> {
const auth_id = this.#authIdFor(user);
const session = await this.stores.session.create(user.id, {
meta: { ...meta, worker: true },
kind: 'web',
last_ip: (meta.ip as string | undefined) ?? null,
last_user_agent: (meta.user_agent as string | undefined) ?? null,
expires_at: nowSeconds() + WORKER_WINDOW_SECONDS,
auth_id,
});
const token = this.#signSessionTypeToken(
'session',
user,
session.uuid,
auth_id,
{ worker: true },
);
const gui_token = this.#signSessionTypeToken(
'gui',
user,
session.uuid,
auth_id,
{ worker: true },
);
return { session, token, gui_token };
}
/**
* Worker variant of `getUserAppToken`. Bypasses the idempotent
* `getOrCreateApp` path (which would return the existing
* interactive app session at WEB/APP_WINDOW_SECONDS) and creates a
* fresh `kind='app'` row tagged `meta.worker = true` with a
* `WORKER_WINDOW_SECONDS` expiry. The emitted JWT is shaped like a
* standard app-under-user token plus a `worker: true` claim so the
* downstream consumer can tell them apart.
*
* NOTE: the v2 `idx_sessions_user_app_active` index ensures one
* active app row per (user, app). A worker session here will
* collide with an existing non-worker app session for the same
* (user, app) pair. Future schema work can carve workers out of
* that uniqueness; for now callers must accept that constraint.
*/
async createWorkerAppToken(actor: Actor, appUid: string): Promise<string> {
if (!actor.user) {
throw new HttpError(403, 'Actor must be a user', {
legacyCode: 'forbidden',
});
}
const auth_id = this.#authIdFor(actor.user as UserRow);
const session = await this.stores.session.create(actor.user.id, {
meta: { worker: true },
kind: 'app',
app_uid: appUid,
expires_at: nowSeconds() + WORKER_WINDOW_SECONDS,
auth_id,
});
return this.services.token.sign('auth', {
type: 'app-under-user',
version: '2',
user_uid: actor.user.uuid,
app_uid: appUid,
session_uid: session.uuid,
auth_id,
worker: true,
});
}
#authIdFor(user: UserRow): string {
return user.uuid;
}
@@ -252,13 +332,6 @@ export class AuthService extends PuterService {
return now + seconds;
}
/**
* Remove the session referenced by a session/GUI JWT. Cascades to
* derived rows (asset cookies parented to this web session) so
* logout transitively kills every cookie minted under the session.
* App authorizations are top-level (no parent) and survive logout
* per the PUT-1010 hierarchy.
*/
async removeSessionByToken(token: string): Promise<void> {
let decoded: AnyTokenPayload;
try {
@@ -277,26 +350,86 @@ export class AuthService extends PuterService {
await this.stores.session.revokeCascade(sessionUuid);
}
/** List all sessions for an actor's user. */
/**
* List sessions surfaced to the manage-sessions UI. Excludes `asset`
* rows (per-cookie children of `web` rows, revoked transitively via
* cascade surfacing them as standalone entries would be confusing).
* App rows are joined to the apps table so the UI can render the
* authorizing app's title and icon without a second round trip.
*/
async listSessions(actor: Actor): Promise<Array<Record<string, unknown>>> {
if (!actor.user?.id) return [];
const rows = await this.stores.session.getByUserId(actor.user.id);
const rows = (await this.stores.session.getByUserId(
actor.user.id,
)) as Array<Record<string, unknown>>;
return rows.map((row: Record<string, unknown>) => {
const visible = rows.filter((row) => row.kind !== 'asset');
const appUids = [
...new Set(
visible
.map((row) => row.app_uid)
.filter(
(uid): uid is string =>
typeof uid === 'string' && uid.length > 0,
),
),
];
const apps = new Map<string, Record<string, unknown>>();
await Promise.all(
appUids.map(async (uid) => {
try {
const app = await this.stores.app.getByUid(uid);
if (app) apps.set(uid, app);
} catch {
// App lookup failures fall back to app_uid only.
}
}),
);
const enriched = visible.map((row) => {
const meta =
(typeof row.meta === 'string'
? JSON.parse(row.meta as string)
: row.meta) ?? {};
const isCurrent = actor.session?.uid === row.uuid;
const appUid = typeof row.app_uid === 'string' ? row.app_uid : null;
const app = appUid ? (apps.get(appUid) ?? null) : null;
return {
...meta,
uuid: row.uuid,
kind: row.kind,
current: isCurrent,
label: row.label ?? null,
created_at: row.created_at,
last_activity: row.last_activity,
current: isCurrent,
...meta,
expires_at: row.expires_at ?? null,
last_ip: row.last_ip ?? null,
created_via: row.created_via ?? null,
app_uid: appUid,
app: app
? {
uid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
}
: null,
};
});
// Sort: current session first, then most-recently-active. The
// manage-sessions UI relies on this so the "you are here" row
// anchors the top of the list.
enriched.sort((a, b) => {
if (a.current !== b.current) return a.current ? -1 : 1;
const al = Number(a.last_activity ?? 0);
const bl = Number(b.last_activity ?? 0);
return bl - al;
});
return enriched;
}
/**
@@ -308,6 +441,235 @@ export class AuthService extends PuterService {
await this.stores.session.revokeCascade(uuid);
}
async revokeAllSessions(
actor: Actor,
opts: { includeCurrent?: boolean; includeApps?: boolean } = {},
): Promise<void> {
if (!actor.user) {
throw new HttpError(403, 'Actor must be a user', {
legacyCode: 'forbidden',
});
}
const currentUuid = actor.session?.uid;
const rows = await this.stores.session.getByUserId(
actor.user.id as number,
);
for (const row of rows) {
if (row.kind === 'web') {
if (!opts.includeCurrent && row.uuid === currentUuid) continue;
await this.stores.session.revokeCascade(row.uuid as string);
} else if (row.kind === 'app' && opts.includeApps) {
await this.stores.session.revokeCascade(row.uuid as string);
}
}
}
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') {
// Per ROLLOUT-1, 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 ─────────────────────────────────────
/**
@@ -540,13 +902,6 @@ export class AuthService extends PuterService {
return typeof uid === 'string' && uid ? uid : null;
}
/**
* Sign an app-under-user token for the given app UID. Idempotent per
* `(user.id, appUid)` repeat opens of the same app reuse the existing
* `kind='app'` session row rather than minting a fresh one. The row is
* top-level (no `parent_session_id`) so signing out of the web session
* doesn't kill the app authorization (PUT-1010 hierarchy).
*/
async getUserAppToken(actor: Actor, appUid: string): Promise<string> {
if (!actor.user)
throw new HttpError(403, 'Actor must be a user', {
@@ -865,7 +1220,12 @@ export class AuthService extends PuterService {
async createAccessToken(
actor: Actor,
permissions: Array<[string, Record<string, unknown>?]>,
options: { expiresIn?: string } = {},
// `expiresIn` follows jsonwebtoken's expiresIn semantics — either
// a number of seconds (integer) or a duration string ('1h',
// '30d'). `#hardExpiryFromExpiresIn` supports both, and existing
// callers / tests pass the string form, so narrowing to `number`
// here would force unsafe casts at every call site.
options: { expiresIn?: string | number } = {},
): Promise<string> {
if (!actor.user)
throw new HttpError(403, 'Actor must be a user', {
@@ -902,6 +1262,10 @@ export class AuthService extends PuterService {
parent_session_id,
expires_at: expiresAt,
auth_id,
// Stored on the session row so a raw-uuid revoke (caller has the
// token_uid but no JWT) can reverse-find the row and flip
// `revoked_at` — see `revokeAccessToken`.
access_token_uid: tokenUid,
},
);
@@ -917,7 +1281,16 @@ export class AuthService extends PuterService {
jwtPayload.app_uid = actor.app.uid;
}
const jwt = this.services.token.sign('auth', jwtPayload, options);
// jsonwebtoken's SignOptions.expiresIn is typed as `number |
// ${number}${unit}` (template literal), so a plain string can't
// be statically proven safe. The runtime accepts the same range
// of strings #hardExpiryFromExpiresIn parses ('1h', '30d'), so
// the cast is faithful to actual behavior.
const jwt = this.services.token.sign(
'auth',
jwtPayload,
options as { expiresIn?: number },
);
// Store each permission grant
const db = this.stores.permission as unknown as {
@@ -999,19 +1372,27 @@ export class AuthService extends PuterService {
}
}
// Permissions rows still DELETE — the AUTH-5 "no DELETE on revoke"
// rule scoped to the `sessions` table (where the audit trail of
// when a session existed/was revoked is load-bearing for forensic
// queries and the cascade graph). `access_token_permissions`
// rows are the grant manifest for an *active* token; once its
// session is soft-revoked, the grants are dead-weight cache
// entries that would only confuse `checkMany`. If we later need
// permission-grant history for audit, that becomes a
// `revoked_at` column on this table, not a behavior change here.
await this.clients.db.write(
'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?',
[tokenUid],
);
await this.stores.permission.invalidateAccessTokenPerms(tokenUid);
// v2 access tokens carry a session row whose `revoked_at` is the
// authoritative kill switch — flip it so a stolen token can't
// resurrect by re-grabbing the deleted permissions. v1 tokens
// (or raw-uuid input where the JWT wasn't presented) have no
// session uuid here; AUTH-5 owns the full back-fill revoke flow.
if (sessionUidFromJwt) {
await this.stores.session.removeByUuid(sessionUidFromJwt);
} else {
const row =
await this.stores.session.findActiveByAccessTokenUid(tokenUid);
if (row) await this.stores.session.removeByUuid(row.uuid);
}
}
+1 -12
View File
@@ -129,7 +129,7 @@ const AUTH_COMPRESSION = def({
app_uid: { short: 'au', ...uuidCompression('app-') },
// v2 unified session-row binding — present on every v2 token kind.
session_uid: { short: 'su', ...uuidCompression() },
// v2 stable per-user identity that survives re-login (PUT-1010).
// v2 stable per-user identity that survives re-login
auth_id: { short: 'ai', ...uuidCompression() },
});
@@ -161,17 +161,6 @@ const COMPRESSION: Record<string, CompressionContext> = {
// ── TokenService ────────────────────────────────────────────────────
/**
* Signs and verifies JWTs.
*
* Two secrets coexist for the v1v2 migration:
* - `jwt_secret_v2` signs every new token (`kid: 'v2'` header).
* - `jwt_secret` is verify-only for tokens minted before this rolled out;
* verified-legacy results carry `legacy: true` so AuthService can
* drive lazy-backfill + the re-auth migration flow (AUTH-4).
*
* `allow_v1_tokens=false` (ROLLOUT-1) hard-rejects v1 tokens at verify.
*/
export class TokenService extends PuterService {
#secretV2: string = '';
#secretLegacy: string = '';
+1 -3
View File
@@ -36,7 +36,7 @@ interface TokenPayloadBase {
type: TokenType;
/** v2: unified session-row binding (uuid of the `sessions` row). */
session_uid?: string;
/** v2: stable per-user identity that survives re-login (PUT-1010). */
/** v2: stable per-user identity that survives re-login */
auth_id?: string;
/** Set by TokenService when the token verified via the legacy secret. */
legacy?: boolean;
@@ -103,12 +103,10 @@ export interface SessionRow {
meta?: Record<string, unknown> | string | null;
created_at?: number | null;
last_activity?: number | null;
/** PUT-1013: 'web' | 'app' | 'access_token' | 'asset'. */
kind?: string | null;
parent_session_id?: string | null;
revoked_at?: number | null;
expires_at?: number | null;
/** PUT-1014: composite-key columns. */
app_uid?: string | null;
legacy_token_uid?: string | null;
created_via?: string | null;
@@ -25,11 +25,6 @@ import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
import type { AuthResult, AuthService } from '../auth/AuthService.js';
import { PuterService } from '../types.js';
/**
* Error carrying the AUTH-4 reauth payload. socket.io forwards `error.data`
* to the client's `connect_error` callback, so clients receive the same
* `{ code, reason, auth_id }` shape the HTTP gate emits.
*/
export type SocketReauthError = Error & { data: Record<string, unknown> };
/**
@@ -322,9 +317,6 @@ export class SocketService extends PuterService {
try {
const result = await authService.authenticate(token);
// Log the AUTH-4 signal here; `decideSocketAuth` stays
// pure (no side effects) for unit testing. The `(ws)`
// suffix lets the same grep find HTTP + socket events.
if (result.reauth) {
console.info(
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'} (ws)`,
+30 -4
View File
@@ -45,8 +45,9 @@ const TOUCH_THROTTLE_MAX_ENTRIES = 10000;
// session never expires. `access_token` rows are *not* slid — their
// `expires_at` is hard-set at mint to the caller-specified value.
// Exported so AuthService can use the same values when seeding new rows.
export const WEB_WINDOW_SECONDS = 30 * 24 * 60 * 60; // 30 days
export const APP_WINDOW_SECONDS = 90 * 24 * 60 * 60; // 90 days
export const WEB_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y
export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y
export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite); TODO DS: have workers pass in flag when creating worker token so that we can give them infinite time
export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days
const sqlTimestamp = (ms) =>
@@ -124,6 +125,10 @@ export class SessionStore extends PuterStore {
* index.
* @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
* presented.
* @param opts.created_via - Audit sentinel (e.g. 'legacy_backfill').
* @param opts.auth_id - Stable per-user identity (survives re-login);
* carried on every v2 JWT so manage-sessions can group by identity.
@@ -152,6 +157,7 @@ export class SessionStore extends PuterStore {
expires_at = null,
app_uid = null,
legacy_token_uid = null,
access_token_uid = null,
created_via = null,
auth_id = null,
} = {},
@@ -171,7 +177,7 @@ export class SessionStore extends PuterStore {
: 'INSERT INTO';
await this.clients.db.write(
`${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`access_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
uuid,
userId,
@@ -186,6 +192,7 @@ export class SessionStore extends PuterStore {
expires_at,
app_uid,
legacy_token_uid,
access_token_uid,
created_via,
auth_id,
],
@@ -206,6 +213,7 @@ export class SessionStore extends PuterStore {
expires_at,
app_uid,
legacy_token_uid,
access_token_uid,
created_via,
auth_id,
};
@@ -282,6 +290,23 @@ export class SessionStore extends PuterStore {
await this.publishCacheKeys({ keys, broadcast: true });
}
/**
* 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;
const now = nowSeconds();
const rows = await this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'access_token' AND (`access_token_uid` = ? OR `legacy_token_uid` = ?) AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` DESC LIMIT 1",
[tokenUid, tokenUid, now],
);
return this.#normalizeRow(rows[0]);
}
/**
* Idempotent "give me the app session for this (user, app)" lookup.
* Returns the existing active app session if one exists, or creates
@@ -299,7 +324,8 @@ export class SessionStore extends PuterStore {
* @param appUid - App UID (string).
* @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 (PUT-1010).
* @param opts.auth_id - Stable per-user identity (survives re-login);
* carried on every v2 JWT so manage-sessions can group by identity.
*/
async getOrCreateApp(userId, appUid, opts = {}) {
if (!userId || !appUid) return null;
@@ -21,6 +21,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { setupTestServer } from '../../testUtil.ts';
import { PuterServer } from '../../server.ts';
import {
APP_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
} from './SessionStore.js';
describe('SessionStore', () => {
let server: PuterServer;
@@ -282,11 +286,13 @@ describe('SessionStore', () => {
expect(row.app_uid).toBe(appUid);
expect(row.parent_session_id).toBeNull();
expect(row.user_id).toBe(user.id);
// Sliding window seeded — 90 days for app.
// Sliding window seeded for app — bound matches the live
// APP_WINDOW_SECONDS constant so a future bump to the window
// doesn't silently fail this assertion.
const now = Math.floor(Date.now() / 1000);
expect(row.expires_at).toBeGreaterThan(now);
expect(row.expires_at).toBeLessThanOrEqual(
now + 90 * 24 * 60 * 60 + 5,
now + APP_WINDOW_SECONDS + 5,
);
});
@@ -370,13 +376,15 @@ describe('SessionStore', () => {
await target.updateActivity(session.uuid, now);
const row = await rawRow(session.uuid);
// After slide, expires_at = now + 30d (within a tolerance
// for between-statement wall-clock drift).
// After slide, expires_at = now + WEB_WINDOW_SECONDS (within
// a tolerance for between-statement wall-clock drift). Use
// the constant directly so a future window bump doesn't
// need a parallel edit here.
expect(row.expires_at).toBeGreaterThanOrEqual(
now + 30 * 24 * 60 * 60 - 5,
now + WEB_WINDOW_SECONDS - 5,
);
expect(row.expires_at).toBeLessThanOrEqual(
now + 30 * 24 * 60 * 60 + 5,
now + WEB_WINDOW_SECONDS + 5,
);
});
+16
View File
@@ -491,6 +491,22 @@ interface IConfigOptional {
* Default true during the v1v2 migration window.
*/
allow_v1_tokens: boolean;
/**
* When false, `POST /auth/migrate-token` returns 410 Gone for v1
* `app-under-user` tokens. Per ROLLOUT-1, 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 may
* call `POST /auth/migrate-token` cross-origin. The main `origin`
* is always allowed. Used to thread the SDK migration call through
* app subdomains (e.g. `*.puter.site`) without opening the
* endpoint to arbitrary attacker pages.
*/
allow_migrate_token_origins?: string[];
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
url_signature_secret: string;
/** Name of the session cookie the auth probe reads. */