@openparachute/hub 0.3.0-rc.1 → 0.5.1
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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
package/src/jwt-sign.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT issuance + verification for hub-issued access tokens, plus opaque
|
|
3
|
+
* refresh-token minting that records hashes in the `tokens` table.
|
|
4
|
+
*
|
|
5
|
+
* Three pieces, deliberately separable:
|
|
6
|
+
* - `signAccessToken(db, opts)` — pure JWT signing. Looks up the active
|
|
7
|
+
* signing key from `signing_keys`, signs an RS256 JWT, returns the
|
|
8
|
+
* compact serialization plus jti + computed expiry. Does NOT write to
|
|
9
|
+
* `tokens` — the caller chooses whether to persist (PR (c) will).
|
|
10
|
+
* - `signRefreshToken(db, opts)` — generates an opaque hex token,
|
|
11
|
+
* SHA-256-hashes it, and inserts a `tokens` row. Returns the plaintext
|
|
12
|
+
* to hand to the client; the hash is what we'll compare on refresh.
|
|
13
|
+
* - `validateAccessToken(db, token)` — verifies the JWT signature against
|
|
14
|
+
* active + recently-retired keys (whatever's currently in JWKS), checks
|
|
15
|
+
* expiry. Read-only.
|
|
16
|
+
*
|
|
17
|
+
* Sliding refresh: PR (c) will rotate the row on a successful refresh; this
|
|
18
|
+
* PR just sets up the storage shape. 30-day expiry is the *initial* TTL.
|
|
19
|
+
*/
|
|
20
|
+
import type { Database } from "bun:sqlite";
|
|
21
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
22
|
+
import {
|
|
23
|
+
type JWTPayload,
|
|
24
|
+
SignJWT,
|
|
25
|
+
decodeProtectedHeader,
|
|
26
|
+
importPKCS8,
|
|
27
|
+
importSPKI,
|
|
28
|
+
jwtVerify,
|
|
29
|
+
} from "jose";
|
|
30
|
+
import { getActiveSigningKey, getAllPublicKeys } from "./signing-keys.ts";
|
|
31
|
+
|
|
32
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
33
|
+
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
34
|
+
export const SIGNING_ALGORITHM = "RS256";
|
|
35
|
+
|
|
36
|
+
export interface SignAccessTokenOpts {
|
|
37
|
+
/** Subject — the user id. */
|
|
38
|
+
sub: string;
|
|
39
|
+
scopes: string[];
|
|
40
|
+
/** Module short name (vault, notes, …) or "hub" — sets `aud`. */
|
|
41
|
+
audience: string;
|
|
42
|
+
clientId: string;
|
|
43
|
+
/**
|
|
44
|
+
* Hub origin — sets the `iss` claim. Required: every consumer (vault,
|
|
45
|
+
* scribe, channel) validates `iss` against `PARACHUTE_HUB_ORIGIN`, and a
|
|
46
|
+
* missing claim is rejected. Callers derive this via `deriveHubOrigin()`
|
|
47
|
+
* or thread it from `OAuthDeps.issuer`.
|
|
48
|
+
*/
|
|
49
|
+
issuer: string;
|
|
50
|
+
/** Override the jti (defaults to random base64url(16)). Used by tests. */
|
|
51
|
+
jti?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Override the default 15-minute access-token TTL. Long-lived tokens
|
|
54
|
+
* (operator-token, ~1y) pass an explicit value here.
|
|
55
|
+
*/
|
|
56
|
+
ttlSeconds?: number;
|
|
57
|
+
now?: () => Date;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SignedAccessToken {
|
|
61
|
+
token: string;
|
|
62
|
+
jti: string;
|
|
63
|
+
expiresAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function signAccessToken(
|
|
67
|
+
db: Database,
|
|
68
|
+
opts: SignAccessTokenOpts,
|
|
69
|
+
): Promise<SignedAccessToken> {
|
|
70
|
+
const key = getActiveSigningKey(db);
|
|
71
|
+
const priv = await importPKCS8(key.privateKeyPem, SIGNING_ALGORITHM);
|
|
72
|
+
const jti = opts.jti ?? randomBytes(16).toString("base64url");
|
|
73
|
+
const nowMs = (opts.now?.() ?? new Date()).getTime();
|
|
74
|
+
const iat = Math.floor(nowMs / 1000);
|
|
75
|
+
const exp = iat + (opts.ttlSeconds ?? ACCESS_TOKEN_TTL_SECONDS);
|
|
76
|
+
const token = await new SignJWT({
|
|
77
|
+
scope: opts.scopes.join(" "),
|
|
78
|
+
client_id: opts.clientId,
|
|
79
|
+
})
|
|
80
|
+
.setProtectedHeader({ alg: SIGNING_ALGORITHM, kid: key.kid })
|
|
81
|
+
.setSubject(opts.sub)
|
|
82
|
+
.setIssuer(opts.issuer)
|
|
83
|
+
.setIssuedAt(iat)
|
|
84
|
+
.setExpirationTime(exp)
|
|
85
|
+
.setAudience(opts.audience)
|
|
86
|
+
.setJti(jti)
|
|
87
|
+
.sign(priv);
|
|
88
|
+
return { token, jti, expiresAt: new Date(exp * 1000).toISOString() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SignRefreshTokenOpts {
|
|
92
|
+
/** Shared with the access token's jti — keys the `tokens` row. */
|
|
93
|
+
jti: string;
|
|
94
|
+
userId: string;
|
|
95
|
+
clientId: string;
|
|
96
|
+
scopes: string[];
|
|
97
|
+
/**
|
|
98
|
+
* Shared identifier across a chain of rotated refresh tokens. Initial
|
|
99
|
+
* issuance (auth-code grant) omits this — a fresh family is minted.
|
|
100
|
+
* Rotation (refresh_token grant) passes the prior row's family_id so
|
|
101
|
+
* replay detection can revoke every descendant in one query (#73).
|
|
102
|
+
*/
|
|
103
|
+
familyId?: string;
|
|
104
|
+
now?: () => Date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SignedRefreshToken {
|
|
108
|
+
/** Opaque token to return to the client. NOT recoverable from the DB. */
|
|
109
|
+
token: string;
|
|
110
|
+
/** SHA-256 hex digest of `token`, stored in `tokens.refresh_token_hash`. */
|
|
111
|
+
refreshTokenHash: string;
|
|
112
|
+
/** Family identifier (new UUID for initial issuance, inherited on rotation). */
|
|
113
|
+
familyId: string;
|
|
114
|
+
expiresAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Thrown when the `tokens` row INSERT fails — most plausibly a UNIQUE jti
|
|
119
|
+
* collision caused by a concurrent rotation racing on the same prior refresh
|
|
120
|
+
* token. Callers in the OAuth grant path catch this and surface a clean
|
|
121
|
+
* `invalid_grant` 400 instead of letting the SQLite error bubble as a 500
|
|
122
|
+
* (#108).
|
|
123
|
+
*/
|
|
124
|
+
export class RefreshTokenInsertError extends Error {
|
|
125
|
+
override name = "RefreshTokenInsertError";
|
|
126
|
+
override cause: unknown;
|
|
127
|
+
constructor(message: string, cause: unknown) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.cause = cause;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function signRefreshToken(db: Database, opts: SignRefreshTokenOpts): SignedRefreshToken {
|
|
134
|
+
const token = randomBytes(32).toString("base64url");
|
|
135
|
+
const refreshTokenHash = createHash("sha256").update(token).digest("hex");
|
|
136
|
+
const now = opts.now?.() ?? new Date();
|
|
137
|
+
const expiresAt = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString();
|
|
138
|
+
const familyId = opts.familyId ?? randomUUID();
|
|
139
|
+
try {
|
|
140
|
+
db.prepare(
|
|
141
|
+
`INSERT INTO tokens (jti, user_id, client_id, scopes, refresh_token_hash, family_id, expires_at, created_at)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
143
|
+
).run(
|
|
144
|
+
opts.jti,
|
|
145
|
+
opts.userId,
|
|
146
|
+
opts.clientId,
|
|
147
|
+
opts.scopes.join(" "),
|
|
148
|
+
refreshTokenHash,
|
|
149
|
+
familyId,
|
|
150
|
+
expiresAt,
|
|
151
|
+
now.toISOString(),
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new RefreshTokenInsertError(
|
|
155
|
+
`failed to insert refresh token row: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
err,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return { token, refreshTokenHash, familyId, expiresAt };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ValidatedAccessToken {
|
|
163
|
+
payload: JWTPayload;
|
|
164
|
+
kid: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verifies a JWT against the kid declared in its protected header, looking
|
|
169
|
+
* up the matching key from `signing_keys`. Active + recently-retired keys
|
|
170
|
+
* (whatever's in JWKS) are accepted; older retired keys throw. Expiry is
|
|
171
|
+
* checked by `jose` automatically.
|
|
172
|
+
*
|
|
173
|
+
* Pass `expectedIssuer` to enforce that the JWT's `iss` claim matches what
|
|
174
|
+
* this hub advertises — the same check vault performs against its own
|
|
175
|
+
* `PARACHUTE_HUB_ORIGIN`. Defense in depth: tokens forged or replayed from
|
|
176
|
+
* a different issuer get rejected at validation as well as issuance.
|
|
177
|
+
*/
|
|
178
|
+
export async function validateAccessToken(
|
|
179
|
+
db: Database,
|
|
180
|
+
token: string,
|
|
181
|
+
expectedIssuer?: string,
|
|
182
|
+
): Promise<ValidatedAccessToken> {
|
|
183
|
+
const header = decodeProtectedHeader(token);
|
|
184
|
+
const kid = header.kid;
|
|
185
|
+
if (!kid) throw new Error("validateAccessToken: token missing kid header");
|
|
186
|
+
const match = getAllPublicKeys(db).find((k) => k.kid === kid);
|
|
187
|
+
if (!match) throw new Error(`validateAccessToken: unknown or expired kid ${kid}`);
|
|
188
|
+
const pub = await importSPKI(match.publicKeyPem, SIGNING_ALGORITHM);
|
|
189
|
+
const { payload } = await jwtVerify(
|
|
190
|
+
token,
|
|
191
|
+
pub,
|
|
192
|
+
expectedIssuer ? { issuer: expectedIssuer } : undefined,
|
|
193
|
+
);
|
|
194
|
+
// RFC 7009 revocation enforcement (#73). OAuth-issued tokens carry a
|
|
195
|
+
// tokens row keyed by jti; if that row is marked revoked, the JWT is
|
|
196
|
+
// dead even though its signature + expiry are still valid. Tokens that
|
|
197
|
+
// never had a row (operator tokens, ad-hoc internal mints) bypass this
|
|
198
|
+
// check — they're not part of the OAuth grant lifecycle.
|
|
199
|
+
if (typeof payload.jti === "string") {
|
|
200
|
+
const row = findTokenRowByJti(db, payload.jti);
|
|
201
|
+
if (row?.revokedAt) throw new Error("validateAccessToken: token has been revoked");
|
|
202
|
+
}
|
|
203
|
+
return { payload, kid };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Convenience for the `tokens` row matching a presented refresh token. Hash
|
|
208
|
+
* the plaintext, look up by hash, return the row if it exists. The caller
|
|
209
|
+
* decides what to do with `revokedAt` — the rotation path treats a revoked
|
|
210
|
+
* row as theft (RFC 6819 §5.2.2.3).
|
|
211
|
+
*/
|
|
212
|
+
export interface RefreshTokenRow {
|
|
213
|
+
jti: string;
|
|
214
|
+
userId: string;
|
|
215
|
+
clientId: string;
|
|
216
|
+
scopes: string[];
|
|
217
|
+
/** Family identifier — shared across rotated descendants (#73). */
|
|
218
|
+
familyId: string;
|
|
219
|
+
expiresAt: string;
|
|
220
|
+
revokedAt: string | null;
|
|
221
|
+
createdAt: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface TokenRowDb {
|
|
225
|
+
jti: string;
|
|
226
|
+
user_id: string;
|
|
227
|
+
client_id: string;
|
|
228
|
+
scopes: string;
|
|
229
|
+
family_id: string | null;
|
|
230
|
+
expires_at: string;
|
|
231
|
+
revoked_at: string | null;
|
|
232
|
+
created_at: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
|
|
236
|
+
return {
|
|
237
|
+
jti: row.jti,
|
|
238
|
+
userId: row.user_id,
|
|
239
|
+
clientId: row.client_id,
|
|
240
|
+
scopes: row.scopes.split(" ").filter((s) => s.length > 0),
|
|
241
|
+
familyId: row.family_id ?? row.jti,
|
|
242
|
+
expiresAt: row.expires_at,
|
|
243
|
+
revokedAt: row.revoked_at,
|
|
244
|
+
createdAt: row.created_at,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function findRefreshToken(db: Database, plaintext: string): RefreshTokenRow | null {
|
|
249
|
+
const refreshTokenHash = createHash("sha256").update(plaintext).digest("hex");
|
|
250
|
+
const row = db
|
|
251
|
+
.query<TokenRowDb, [string]>("SELECT * FROM tokens WHERE refresh_token_hash = ? LIMIT 1")
|
|
252
|
+
.get(refreshTokenHash);
|
|
253
|
+
return row ? rowToRefreshToken(row) : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Look up a tokens row by jti. Used by the revocation endpoint to find an
|
|
257
|
+
* access-token row from its JWT jti claim, and by validateAccessToken to
|
|
258
|
+
* honor revoked_at. */
|
|
259
|
+
export function findTokenRowByJti(db: Database, jti: string): RefreshTokenRow | null {
|
|
260
|
+
const row = db.query<TokenRowDb, [string]>("SELECT * FROM tokens WHERE jti = ? LIMIT 1").get(jti);
|
|
261
|
+
return row ? rowToRefreshToken(row) : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Revoke every row in a refresh-token family. Called by the refresh handler
|
|
266
|
+
* when an already-revoked refresh token is presented again — the spec-defined
|
|
267
|
+
* theft signal (RFC 6819 §5.2.2.3). Idempotent: rows already revoked keep
|
|
268
|
+
* their existing revoked_at.
|
|
269
|
+
*/
|
|
270
|
+
export function revokeFamily(db: Database, familyId: string, now: Date): number {
|
|
271
|
+
const res = db
|
|
272
|
+
.prepare("UPDATE tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL")
|
|
273
|
+
.run(now.toISOString(), familyId);
|
|
274
|
+
return Number(res.changes);
|
|
275
|
+
}
|