@openparachute/hub 0.7.7-rc.6 → 0.7.7-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-api.test.ts +287 -27
- package/src/__tests__/account-session.test.ts +244 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/account-api.ts +111 -66
- package/src/account-session.ts +132 -0
- package/src/api-invites.ts +19 -0
- package/src/hub-server.ts +35 -5
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
- package/src/sessions.ts +66 -30
package/src/hub-server.ts
CHANGED
|
@@ -114,6 +114,13 @@
|
|
|
114
114
|
* (hub#473; reached after a correct
|
|
115
115
|
* password for a 2FA-enrolled user)
|
|
116
116
|
* /logout (POST) → end admin session
|
|
117
|
+
* /account/session (GET) → same-origin boot oracle {signed_in,
|
|
118
|
+
* csrf,...} (hub-parity P1); cookie-gated,
|
|
119
|
+
* NOT Bearer — mounted ABOVE the
|
|
120
|
+
* force-change-password gate below (a pure
|
|
121
|
+
* state oracle, so a pre-rotation user can
|
|
122
|
+
* still read it to drive the rotation
|
|
123
|
+
* ceremony itself)
|
|
117
124
|
* /account/change-password (GET + POST) → user self-service change-password
|
|
118
125
|
* (force-redirect target for users
|
|
119
126
|
* with password_changed=false; also
|
|
@@ -122,7 +129,8 @@
|
|
|
122
129
|
* + the per-vault proxy is hard-gated
|
|
123
130
|
* per-request on password_changed===true
|
|
124
131
|
* (forceChangePasswordGate); only this
|
|
125
|
-
* route
|
|
132
|
+
* route, /logout, and /account/session
|
|
133
|
+
* (P1, also above the gate) stay reachable
|
|
126
134
|
* pre-rotation.
|
|
127
135
|
* /account/token (POST) → cookie→account-bearer mint (App
|
|
128
136
|
* campaign Phase 2 H1): session cookie →
|
|
@@ -190,6 +198,7 @@ import {
|
|
|
190
198
|
handleAccountSetVaultCaps,
|
|
191
199
|
requireAnyScope,
|
|
192
200
|
} from "./account-api.ts";
|
|
201
|
+
import { type AccountSessionDeps, handleAccountSession } from "./account-session.ts";
|
|
193
202
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
194
203
|
import { handleAccountToken } from "./account-token.ts";
|
|
195
204
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
@@ -2853,7 +2862,13 @@ export function hubFetch(
|
|
|
2853
2862
|
if (req.method === "OPTIONS") {
|
|
2854
2863
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
2855
2864
|
}
|
|
2856
|
-
|
|
2865
|
+
if (!getDb) {
|
|
2866
|
+
return new Response('{"error":"account descriptor unavailable: db not configured"}', {
|
|
2867
|
+
status: 503,
|
|
2868
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
|
|
2857
2872
|
const merged = new Headers(res.headers);
|
|
2858
2873
|
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
2859
2874
|
return new Response(res.body, { status: res.status, headers: merged });
|
|
@@ -3746,6 +3761,20 @@ export function hubFetch(
|
|
|
3746
3761
|
return new Response("method not allowed", { status: 405 });
|
|
3747
3762
|
}
|
|
3748
3763
|
|
|
3764
|
+
// /account/session — the same-origin boot oracle (hub-parity P1). A
|
|
3765
|
+
// PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
|
|
3766
|
+
// force-change-password gate immediately below: a pre-rotation user
|
|
3767
|
+
// must still be able to read `{signed_in, csrf}` to drive the rotation
|
|
3768
|
+
// ceremony itself, rather than get walled off before it can even ask.
|
|
3769
|
+
// `/account/token` (below the gate) stays gated — a pre-rotation user
|
|
3770
|
+
// hits its 403 `force_change_password` there instead. See
|
|
3771
|
+
// `account-session.ts`.
|
|
3772
|
+
if (pathname === "/account/session") {
|
|
3773
|
+
if (!getDb) return dbNotConfigured();
|
|
3774
|
+
const sessionDeps: AccountSessionDeps = { db: getDb() };
|
|
3775
|
+
return handleAccountSession(req, sessionDeps);
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3749
3778
|
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
|
|
3750
3779
|
// every `/account/*` route BELOW this line is gated. `/logout` and
|
|
3751
3780
|
// `/account/change-password` (the rotation/exit path) ran above and already
|
|
@@ -3937,9 +3966,10 @@ export function hubFetch(
|
|
|
3937
3966
|
}
|
|
3938
3967
|
return new Response("method not allowed", { status: 405 });
|
|
3939
3968
|
}
|
|
3940
|
-
// GET /account (Bearer) — account bootstrap {
|
|
3941
|
-
// intercepts when an
|
|
3942
|
-
//
|
|
3969
|
+
// GET /account (Bearer) — account bootstrap {id,email,door} (the
|
|
3970
|
+
// door-contract's AccountBootstrap). Only intercepts when an
|
|
3971
|
+
// Authorization header is present, so the cookie-authed HTML account
|
|
3972
|
+
// home at `/account`/`/account/` below stays unaffected.
|
|
3943
3973
|
if (
|
|
3944
3974
|
(pathname === "/account" || pathname === "/account/") &&
|
|
3945
3975
|
req.headers.get("authorization")
|
package/src/hub-settings.ts
CHANGED
|
@@ -122,7 +122,21 @@ export type HubSettingKey =
|
|
|
122
122
|
// wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
|
|
123
123
|
// not-yet-set-up hub still lands on setup, not a surface that can't work
|
|
124
124
|
// yet.
|
|
125
|
-
| "root_redirect"
|
|
125
|
+
| "root_redirect"
|
|
126
|
+
// hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
|
|
127
|
+
// `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
|
|
128
|
+
// can advertise `signup_path` without being able to reconstruct it from the
|
|
129
|
+
// `invites` table (which stores only `sha256(token)`, see invites.ts's
|
|
130
|
+
// module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
|
|
131
|
+
// after a successful multi-use `issueInvite`; read (and lazily cleared once
|
|
132
|
+
// stale) by `invites.ts`'s `activePublicSignupPath`.
|
|
133
|
+
//
|
|
134
|
+
// This does NOT weaken the hash-only posture of single-use friend invites
|
|
135
|
+
// (`max_uses === 1`, the default) — those NEVER write this row. A
|
|
136
|
+
// multi-use link is, by construction, the operator's deliberately PUBLIC
|
|
137
|
+
// signup page (minted to broadcast), so persisting its raw token is no
|
|
138
|
+
// more sensitive than the link itself already being handed out widely.
|
|
139
|
+
| "public_signup_token";
|
|
126
140
|
|
|
127
141
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
128
142
|
|
package/src/invites.ts
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
*/
|
|
58
58
|
import type { Database } from "bun:sqlite";
|
|
59
59
|
import { createHash, randomBytes } from "node:crypto";
|
|
60
|
+
import { deleteSetting, getSetting } from "./hub-settings.ts";
|
|
60
61
|
|
|
61
62
|
/** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
|
|
62
63
|
export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
|
|
|
502
503
|
.run(now.toISOString(), vaultName);
|
|
503
504
|
return Number(res.changes);
|
|
504
505
|
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
|
|
509
|
+
* The hub cannot derive a redemption URL from the `invites` table (only
|
|
510
|
+
* `sha256(token)` is stored, never the raw value — see the module doc), so
|
|
511
|
+
* this reads back the ONE raw token persisted at mint time for a
|
|
512
|
+
* deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
|
|
513
|
+
* `handleCreateInvite`, which writes `hub_settings.public_signup_token`
|
|
514
|
+
* after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
|
|
515
|
+
*
|
|
516
|
+
* Returns `/account/setup/<token>` when the persisted invite is still
|
|
517
|
+
* `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
|
|
518
|
+
* (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
|
|
519
|
+
* miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
|
|
520
|
+
* shared-vault link) — and lazily clears the stale setting in that case. No
|
|
521
|
+
* separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
|
|
522
|
+
* all four terminal states from the invite's own columns, so every miss flows
|
|
523
|
+
* through this one status check.
|
|
524
|
+
*
|
|
525
|
+
* The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
|
|
526
|
+
* of `handleCreateInvite`'s write condition (which only persists for a
|
|
527
|
+
* multi-use provisioning link): even a hand-edited settings row pointing at a
|
|
528
|
+
* single-use OR a shared-vault (`provision_vault=false`) invite must never be
|
|
529
|
+
* advertised as public signup — a shared-vault link would leak team-vault write
|
|
530
|
+
* on the anonymous, wildcard-CORS descriptor.
|
|
531
|
+
*/
|
|
532
|
+
export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
|
|
533
|
+
const raw = getSetting(db, "public_signup_token");
|
|
534
|
+
if (!raw) return null;
|
|
535
|
+
const invite = findInviteByRawToken(db, raw);
|
|
536
|
+
if (
|
|
537
|
+
!invite ||
|
|
538
|
+
inviteStatus(invite, now) !== "pending" ||
|
|
539
|
+
invite.maxUses <= 1 ||
|
|
540
|
+
!invite.provisionVault
|
|
541
|
+
) {
|
|
542
|
+
deleteSetting(db, "public_signup_token");
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
return `/account/setup/${encodeURIComponent(raw)}`;
|
|
546
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -4,12 +4,21 @@
|
|
|
4
4
|
* with that cookie skip the login form and go straight to consent.
|
|
5
5
|
*
|
|
6
6
|
* Stored in `sessions` (one row per active session), so logout / forced
|
|
7
|
-
* revocation is just a delete. Sessions are
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* ~10 min while a tab
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* revocation is just a delete. Sessions are ROLLING (hub-parity P1, Q4 —
|
|
8
|
+
* adopts cloud's posture): `expires_at` starts at `created_at + SESSION_TTL_MS`
|
|
9
|
+
* (90 days), and {@link touchSession} pushes it forward on genuine activity
|
|
10
|
+
* (the admin SPA re-mints `/admin/host-admin-token` every ~10 min while a tab
|
|
11
|
+
* is open; `GET /account/session`, the app's boot/poll oracle, slides once it
|
|
12
|
+
* crosses {@link SESSION_SLIDE_THRESHOLD_MS} of its life — see
|
|
13
|
+
* `account-session.ts`). There is NO absolute ceiling: an ACTIVELY-used
|
|
14
|
+
* session rolls forward indefinitely, while an idle one (no more touches)
|
|
15
|
+
* still lapses at the 90-day mark. Was 24h sliding / 30d hard cap through
|
|
16
|
+
* 2026-07; the cap is gone. The ONLY thing that terminates a session row today
|
|
17
|
+
* is logout (which deletes it). Note what does NOT: the admin screen-lock gates
|
|
18
|
+
* token MINTS (it does not touch or expire the session row), and per-user
|
|
19
|
+
* session revocation does not exist yet (P6-era). So an actively-used session
|
|
20
|
+
* — including a stolen cookie — currently lives on until 90 idle days or an
|
|
21
|
+
* explicit logout; a per-user revoke lever is the standing gap to close.
|
|
13
22
|
*
|
|
14
23
|
* The cookie value is the session id directly. It's a 32-byte base64url
|
|
15
24
|
* random; collision is statistically impossible. No HMAC needed because the
|
|
@@ -20,16 +29,25 @@ import type { Database } from "bun:sqlite";
|
|
|
20
29
|
import { randomBytes } from "node:crypto";
|
|
21
30
|
|
|
22
31
|
export const SESSION_COOKIE_NAME = "parachute_hub_session";
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Session lifetime — 90 days (hub-parity P1, Q4: adopts cloud's rolling
|
|
34
|
+
* posture). A session (and its cookie Max-Age) lasts 90 days from its last
|
|
35
|
+
* slide; {@link findSession} still expires it hard past this, and logout
|
|
36
|
+
* deletes the row outright. Was 24h (sliding, 30d hard cap) prior to this PR.
|
|
37
|
+
*/
|
|
38
|
+
export const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
24
39
|
|
|
25
40
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
41
|
+
* Roll a session forward once it has been used past this much of its life
|
|
42
|
+
* (30 days) — so an ACTIVE session stays alive indefinitely, while an idle one
|
|
43
|
+
* still expires at the full 90-day TTL. Callers that slide on every touch
|
|
44
|
+
* (the admin SPA's host-admin-token re-mint) don't need this threshold; it's
|
|
45
|
+
* for a POLLING caller like `GET /account/session` (the app's `/check-email`
|
|
46
|
+
* screen hits it every few seconds) that must NOT rewrite the row on every
|
|
47
|
+
* request — see `shouldSlideSession` there. Mirrors cloud's
|
|
48
|
+
* `SESSION_REFRESH_THRESHOLD_MS` (`workers/identity/src/sessions.ts`).
|
|
31
49
|
*/
|
|
32
|
-
export const
|
|
50
|
+
export const SESSION_SLIDE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
51
|
|
|
34
52
|
export interface Session {
|
|
35
53
|
id: string;
|
|
@@ -91,30 +109,33 @@ export function findSession(
|
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/**
|
|
94
|
-
* Slide a session's expiry forward to `now + SESSION_TTL_MS
|
|
95
|
-
*
|
|
112
|
+
* Slide a session's expiry forward to `now + SESSION_TTL_MS`. No-op when the
|
|
113
|
+
* session doesn't exist. NO ceiling (hub-parity P1, Q4) — cloud's posture,
|
|
114
|
+
* adopted here: an ACTIVELY-used session rolls forward forever; a closed tab
|
|
115
|
+
* / idle client (no more touches) still expires `SESSION_TTL_MS` after its
|
|
116
|
+
* last activity. The old absolute cap (`SESSION_MAX_LIFETIME_MS`, 30d) is
|
|
117
|
+
* removed — the bounds on a rolling session are logout, the admin
|
|
118
|
+
* screen-lock, and (P6-era) per-user delete, not a lifetime ceiling.
|
|
96
119
|
*
|
|
97
|
-
* This is what makes sessions sliding rather than fixed-
|
|
98
|
-
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
99
|
-
* and each successful mint calls this
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
120
|
+
* This is what makes sessions sliding rather than fixed-TTL: the admin SPA
|
|
121
|
+
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
122
|
+
* open, and each successful mint calls this unconditionally; `GET
|
|
123
|
+
* /account/session` (the app's boot/poll oracle) instead calls this only
|
|
124
|
+
* past `SESSION_SLIDE_THRESHOLD_MS` of remaining life (bounded slide — see
|
|
125
|
+
* `account-session.ts`), since it's polled every few seconds and an
|
|
126
|
+
* unconditional touch there would rewrite the row on every poll.
|
|
103
127
|
*
|
|
104
128
|
* Monotonic in practice: the production wall clock only moves forward, so the
|
|
105
|
-
* slid value never undershoots a previously-written expiry
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
129
|
+
* slid value never undershoots a previously-written expiry. (The write is
|
|
130
|
+
* unconditional — it does not read the current expiry — so an injected
|
|
131
|
+
* backward `now` in tests would shorten the session: a conservative failure
|
|
132
|
+
* mode, not a security issue.) `now` is injectable for tests, matching
|
|
133
|
+
* {@link findSession}.
|
|
110
134
|
*/
|
|
111
135
|
export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
|
|
112
136
|
const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
113
137
|
if (!row) return;
|
|
114
|
-
const
|
|
115
|
-
const slidMs = nowMs + SESSION_TTL_MS;
|
|
116
|
-
const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
|
|
117
|
-
const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
|
|
138
|
+
const newExpiresAt = new Date(now().getTime() + SESSION_TTL_MS).toISOString();
|
|
118
139
|
db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -122,6 +143,21 @@ export function deleteSession(db: Database, id: string): void {
|
|
|
122
143
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
123
144
|
}
|
|
124
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Should this live session be rolled forward? True once it has been used past
|
|
148
|
+
* {@link SESSION_SLIDE_THRESHOLD_MS} of its life — i.e. its remaining life has
|
|
149
|
+
* dropped below `SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS`. A
|
|
150
|
+
* freshly-created/-slid session is NOT slid; one that's crossed the threshold
|
|
151
|
+
* is. Pure — the caller (`account-session.ts`'s bounded slide, the G3 twin of
|
|
152
|
+
* cloud's `shouldSlideSession`) does the write ({@link touchSession}) + cookie
|
|
153
|
+
* re-issue only when this returns true, bounding both to ~once per threshold
|
|
154
|
+
* per session even under frequent polling.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldSlideSession(session: Session, now: () => Date = () => new Date()): boolean {
|
|
157
|
+
const remainingMs = new Date(session.expiresAt).getTime() - now().getTime();
|
|
158
|
+
return remainingMs < SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS;
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
/**
|
|
126
162
|
* Build a `Set-Cookie` header value for the given session id. HttpOnly +
|
|
127
163
|
* SameSite=Lax + Secure (conditional) + Path=/.
|