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