@baseworks/auth 0.2.5 → 1.0.2

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/dist/hono.d.ts CHANGED
@@ -5,17 +5,24 @@ interface ServiceAuthUser {
5
5
  userId: string;
6
6
  orgId?: string;
7
7
  role?: string;
8
- tokenType: 'user' | 'service';
8
+ email?: string;
9
+ tokenType: 'user' | 'service' | 'assistant';
9
10
  }
10
11
  declare module 'hono' {
11
12
  interface ContextVariableMap {
12
13
  auth: ServiceAuthUser;
13
14
  }
14
15
  }
16
+ /** Tests change the environment between cases. */
17
+ declare function resetAuthVerifier(): void;
15
18
  /**
16
- * Hono middleware — verifies HS256 JWT from Authorization: Bearer header.
17
- * Reads JWT_SECRET from process.env.
18
- * Sets c.var.auth on success.
19
+ * Hono middleware — verifies the realm's identity token from
20
+ * `Authorization: Bearer` and sets `c.var.auth`.
21
+ *
22
+ * ES256 verified against the realm's published JWKS, plus HS256 while a legacy
23
+ * secret is configured. The shared secret is a migration door: any service
24
+ * holding it can also MINT tokens, including one claiming to be another user,
25
+ * so it should be removed once the realm issues ES256 everywhere.
19
26
  */
20
27
  declare function requireAuth(c: Context, next: Next): Promise<void | (Response & hono.TypedResponse<{
21
28
  error: string;
@@ -23,4 +30,4 @@ declare function requireAuth(c: Context, next: Next): Promise<void | (Response &
23
30
  error: string;
24
31
  }, 500, "json">)>;
25
32
 
26
- export { type ServiceAuthUser, requireAuth };
33
+ export { type ServiceAuthUser, requireAuth, resetAuthVerifier };
package/dist/hono.js CHANGED
@@ -1,24 +1,52 @@
1
1
  import {
2
- verifyHs256Jwt
3
- } from "./chunk-OD5XLL3O.js";
2
+ verifier
3
+ } from "./chunk-3XJIAYEG.js";
4
4
 
5
5
  // src/hono.ts
6
+ function fromEnv(env) {
7
+ const jwksUri = env["AUTH_JWKS_URI"];
8
+ const legacy = env["JWT_SECRET"];
9
+ if (!jwksUri && !legacy) {
10
+ return { error: "neither AUTH_JWKS_URI nor JWT_SECRET is configured" };
11
+ }
12
+ return verifier({
13
+ ...jwksUri ? { jwksUri } : {},
14
+ ...env["AUTH_ISSUER"] ? { issuer: env["AUTH_ISSUER"] } : {},
15
+ ...env["AUTH_AUDIENCE"] ? { audience: env["AUTH_AUDIENCE"] } : {},
16
+ ...legacy ? { legacyHs256Secret: legacy } : {}
17
+ });
18
+ }
19
+ var cachedVerifier;
20
+ function resetAuthVerifier() {
21
+ cachedVerifier = void 0;
22
+ }
23
+ function claimsToUser(claims) {
24
+ const type = claims.type;
25
+ return {
26
+ userId: String(claims.sub ?? ""),
27
+ orgId: claims["org_id"],
28
+ role: claims["role"],
29
+ email: claims["email"],
30
+ // Anything unrecognised is treated as a user token: `service` and
31
+ // `assistant` open doors a plain user token must not, so they are named
32
+ // explicitly and everything else falls to the least privileged reading.
33
+ tokenType: type === "service" ? "service" : type === "assistant" ? "assistant" : "user"
34
+ };
35
+ }
6
36
  async function requireAuth(c, next) {
7
37
  const header = c.req.header("Authorization");
8
38
  if (!header?.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
9
- const token = header.slice(7);
10
- const secret = process.env["JWT_SECRET"];
11
- if (!secret) return c.json({ error: "JWT_SECRET not configured" }, 500);
12
- const claims = verifyHs256Jwt(token, secret);
39
+ cachedVerifier ??= fromEnv(process.env);
40
+ if (typeof cachedVerifier !== "function") {
41
+ console.error(`[auth] ${cachedVerifier.error}`);
42
+ return c.json({ error: "auth is not configured" }, 500);
43
+ }
44
+ const claims = await cachedVerifier(header.slice(7));
13
45
  if (!claims) return c.json({ error: "Unauthorized" }, 401);
14
- c.set("auth", {
15
- userId: String(claims["sub"] ?? ""),
16
- orgId: claims["org_id"],
17
- role: claims["role"],
18
- tokenType: claims["type"] === "service" ? "service" : "user"
19
- });
46
+ c.set("auth", claimsToUser(claims));
20
47
  return next();
21
48
  }
22
49
  export {
23
- requireAuth
50
+ requireAuth,
51
+ resetAuthVerifier
24
52
  };
@@ -0,0 +1,95 @@
1
+ import { JWK, JSONWebKeySet } from 'jose';
2
+
3
+ /**
4
+ * Realm identity tokens: minting, publication and verification.
5
+ *
6
+ * One realm has one issuer — its auth-service — and one signing key pair. Every
7
+ * other service in the realm (org, iam, billing, pay, and each product API)
8
+ * verifies with the PUBLIC key it fetches from `/.well-known/jwks.json`, and
9
+ * therefore cannot mint anything.
10
+ *
11
+ * That asymmetry is the whole point. With a shared HMAC secret, every service
12
+ * holding it can also *issue* tokens — including one that claims to be another
13
+ * user. This is not theoretical: a product API was found minting
14
+ * `{ sub: <some other user>, type: 'user' }` to write a membership on that
15
+ * user's behalf. Under ES256 that is impossible by construction: the private
16
+ * key never leaves auth-service.
17
+ *
18
+ * Claims: `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `type`. Org and role are
19
+ * deliberately absent — membership changes while a token lives, so a token that
20
+ * carries a role goes stale and lies. Consumers ask the org service instead.
21
+ *
22
+ * HS256 stays available during migration; see `verifier()` below.
23
+ */
24
+
25
+ declare const IDENTITY_ALG = "ES256";
26
+ /** `human` signs in; `service` is a service acting as itself; `assistant` is a narrowed token. */
27
+ type IdentityType = 'human' | 'service' | 'assistant';
28
+ interface IdentityClaims {
29
+ sub: string;
30
+ type: IdentityType;
31
+ iss?: string;
32
+ aud?: string;
33
+ exp?: number;
34
+ iat?: number;
35
+ jti?: string;
36
+ /** Narrowed tokens carry the org they are scoped to; identity tokens do not. */
37
+ org_id?: string;
38
+ scope?: string;
39
+ [key: string]: unknown;
40
+ }
41
+ interface SigningKey {
42
+ privateKey: CryptoKey;
43
+ /** Published in the JWKS and in every token header, so verifiers pick the right key. */
44
+ kid: string;
45
+ publicJwk: JWK;
46
+ }
47
+ /** Load the signing key pair from a PKCS#8 private key. */
48
+ declare function loadSigningKey(privateKeyPem: string, kid?: string): Promise<SigningKey>;
49
+ /** Load a public key (SPKI PEM) for publication only — a retired key during rotation. */
50
+ declare function loadPublicKey(publicKeyPem: string, kid?: string): Promise<JWK>;
51
+ /**
52
+ * The document served at `/.well-known/jwks.json`.
53
+ *
54
+ * Rotation works by publishing both keys for longer than a token lives: add the
55
+ * new key, sign with it, keep the old one listed until every token signed by it
56
+ * has expired, then drop it.
57
+ */
58
+ declare function jwks(signing: SigningKey, retired?: JWK[]): JSONWebKeySet;
59
+ interface MintOptions {
60
+ issuer: string;
61
+ audience: string;
62
+ expiresInSeconds: number;
63
+ }
64
+ declare function mint(key: SigningKey, claims: IdentityClaims, opts: MintOptions): Promise<string>;
65
+ interface VerifierOptions {
66
+ /** Where to fetch the realm's public keys. jose caches and refetches on an unknown `kid`. */
67
+ jwksUri?: string;
68
+ /** A fixed key set instead of a URL — for tests, or a service that ships with the realm. */
69
+ jwks?: JSONWebKeySet;
70
+ issuer?: string;
71
+ audience?: string;
72
+ /**
73
+ * Accept HS256 tokens signed with this shared secret, in addition to ES256.
74
+ *
75
+ * Migration only. While it is set, any holder of the secret can still mint
76
+ * tokens for this realm — including tokens impersonating a user — so it must
77
+ * be removed once every issuer has moved to ES256.
78
+ */
79
+ legacyHs256Secret?: string;
80
+ }
81
+ interface Verifier {
82
+ (token: string): Promise<IdentityClaims | null>;
83
+ }
84
+ /**
85
+ * Build a verifier for the realm.
86
+ *
87
+ * Returns `null` rather than throwing on every rejection: a caller answering an
88
+ * HTTP request wants one 401 path, not a taxonomy of crypto errors. Reasons
89
+ * worth acting on (an unreachable JWKS, say) surface through `onError`.
90
+ */
91
+ declare function verifier(opts: VerifierOptions & {
92
+ onError?: (err: unknown) => void;
93
+ }): Verifier;
94
+
95
+ export { IDENTITY_ALG, type IdentityClaims, type IdentityType, type MintOptions, type SigningKey, type Verifier, type VerifierOptions, jwks, loadPublicKey, loadSigningKey, mint, verifier };
@@ -0,0 +1,16 @@
1
+ import {
2
+ IDENTITY_ALG,
3
+ jwks,
4
+ loadPublicKey,
5
+ loadSigningKey,
6
+ mint,
7
+ verifier
8
+ } from "./chunk-3XJIAYEG.js";
9
+ export {
10
+ IDENTITY_ALG,
11
+ jwks,
12
+ loadPublicKey,
13
+ loadSigningKey,
14
+ mint,
15
+ verifier
16
+ };
package/dist/index.d.ts CHANGED
@@ -4,4 +4,3 @@ export { OidcAuthUrlConfig, PkceChallenge, buildOidcAuthUrl, generatePkce } from
4
4
  export { CliAuthResult, CliAuthStart, PollOptions, pollCliAuth, startCliAuth } from './cli-auth.js';
5
5
  export { hashToken, looksLikeJwt, stripBearer } from './token.js';
6
6
  export { JwtClaims, signHs256Jwt, verifyHs256Jwt } from './jwt.js';
7
- export { buildAccountProfileUrl, buildAuthLoginUrl, buildAuthLogoutUrl, buildPublicUrl, getAccountPublicUrl, getAppBasePath, getAuthPublicUrl, getPublicSiteUrl, normalizeUrlLike } from './url-helpers.js';
package/dist/index.js CHANGED
@@ -4,49 +4,29 @@ import {
4
4
  import {
5
5
  verifyOidcToken
6
6
  } from "./chunk-BL74TFCV.js";
7
+ import {
8
+ buildOidcAuthUrl,
9
+ generatePkce
10
+ } from "./chunk-34E5PYNA.js";
7
11
  import {
8
12
  pollCliAuth,
9
13
  startCliAuth
10
- } from "./chunk-C4V5LCFA.js";
14
+ } from "./chunk-GXDYEKX7.js";
11
15
  import {
12
16
  hashToken,
13
17
  looksLikeJwt,
14
18
  stripBearer
15
19
  } from "./chunk-VBIQJKUU.js";
16
- import {
17
- buildAccountProfileUrl,
18
- buildAuthLoginUrl,
19
- buildAuthLogoutUrl,
20
- buildPublicUrl,
21
- getAccountPublicUrl,
22
- getAppBasePath,
23
- getAuthPublicUrl,
24
- getPublicSiteUrl,
25
- normalizeUrlLike
26
- } from "./chunk-3NJASEF4.js";
27
20
  import {
28
21
  signHs256Jwt,
29
22
  verifyHs256Jwt
30
23
  } from "./chunk-OD5XLL3O.js";
31
- import {
32
- buildOidcAuthUrl,
33
- generatePkce
34
- } from "./chunk-34E5PYNA.js";
35
24
  export {
36
- buildAccountProfileUrl,
37
- buildAuthLoginUrl,
38
- buildAuthLogoutUrl,
39
25
  buildOidcAuthUrl,
40
- buildPublicUrl,
41
26
  createOidcHumanResolver,
42
27
  generatePkce,
43
- getAccountPublicUrl,
44
- getAppBasePath,
45
- getAuthPublicUrl,
46
- getPublicSiteUrl,
47
28
  hashToken,
48
29
  looksLikeJwt,
49
- normalizeUrlLike,
50
30
  pollCliAuth,
51
31
  signHs256Jwt,
52
32
  startCliAuth,
package/dist/jwt.d.ts CHANGED
@@ -6,6 +6,7 @@ interface JwtClaims {
6
6
  iat?: number;
7
7
  org_id?: string;
8
8
  role?: string;
9
+ email?: string;
9
10
  type?: string;
10
11
  [key: string]: unknown;
11
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baseworks/auth",
3
- "version": "0.2.5",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -10,43 +10,39 @@
10
10
  "./cli-auth": "./dist/cli-auth.js",
11
11
  "./cli": "./dist/cli.js",
12
12
  "./token": "./dist/token.js",
13
- "./session": "./dist/session.js",
14
- "./url-helpers": "./dist/url-helpers.js",
15
- "./edge": "./dist/edge.js",
16
13
  "./jwt": "./dist/jwt.js",
14
+ "./identity": "./dist/identity.js",
17
15
  "./hono": "./dist/hono.js"
18
16
  },
19
17
  "files": [
20
18
  "dist",
21
19
  "src"
22
20
  ],
23
- "scripts": {
24
- "build": "tsup",
25
- "typecheck": "tsc --noEmit"
26
- },
27
21
  "dependencies": {
28
22
  "@baseworks/core": "^0.2.1",
29
- "jose": "^6.2.3"
23
+ "@baseworks/cli": "^0.2.1",
24
+ "jose": "^6.2.3",
25
+ "commander": "^15.0.0"
30
26
  },
31
27
  "peerDependencies": {
32
- "hono": ">=4.0.0",
33
- "next": ">=15.0.0"
28
+ "hono": ">=4.0.0"
34
29
  },
35
30
  "peerDependenciesMeta": {
36
- "next": {
37
- "optional": true
38
- },
39
31
  "hono": {
40
32
  "optional": true
41
33
  }
42
34
  },
43
35
  "devDependencies": {
44
- "@baseworks/cli": "workspace:*",
45
- "@baseworks/tsconfig": "workspace:*",
46
36
  "@types/node": "^26.0.1",
47
- "commander": "^15.0.0",
48
37
  "hono": "^4.12.27",
49
38
  "tsup": "^8.5.1",
50
- "typescript": "^5.6.0"
39
+ "typescript": "^5.6.0",
40
+ "vitest": "^4.1.9",
41
+ "@baseworks/tsconfig": "0.1.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
51
47
  }
52
- }
48
+ }
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { exportPKCS8, exportSPKI, generateKeyPair } from 'jose'
3
+ import { jwks, loadPublicKey, loadSigningKey, mint, verifier } from '../identity.js'
4
+
5
+ const ISS = 'https://auth.example.test'
6
+ const AUD = 'example-realm'
7
+
8
+ async function freshKey() {
9
+ const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true })
10
+ return { pkcs8: await exportPKCS8(privateKey), spki: await exportSPKI(publicKey) }
11
+ }
12
+
13
+ describe('identity tokens', () => {
14
+ it('mints and verifies a token against the published JWKS', async () => {
15
+ const { pkcs8 } = await freshKey()
16
+ const key = await loadSigningKey(pkcs8)
17
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
18
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
19
+ })
20
+
21
+ const verify = verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })
22
+ const claims = await verify(token)
23
+
24
+ expect(claims?.sub).toBe('usr_1')
25
+ expect(claims?.type).toBe('human')
26
+ expect(claims?.iss).toBe(ISS)
27
+ expect(claims?.aud).toBe(AUD)
28
+ expect(claims?.jti).toBeTruthy()
29
+ })
30
+
31
+ it('never publishes the private half', async () => {
32
+ const { pkcs8 } = await freshKey()
33
+ const key = await loadSigningKey(pkcs8)
34
+ const published = JSON.stringify(jwks(key))
35
+
36
+ expect(published).not.toContain('"d"')
37
+ expect(key.publicJwk.kid).toBeTruthy()
38
+ expect(key.publicJwk.alg).toBe('ES256')
39
+ })
40
+
41
+ it('rejects a token signed by another realm', async () => {
42
+ const mine = await loadSigningKey((await freshKey()).pkcs8)
43
+ const theirs = await loadSigningKey((await freshKey()).pkcs8)
44
+
45
+ const token = await mint(theirs, { sub: 'usr_1', type: 'human' }, {
46
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
47
+ })
48
+
49
+ expect(await verifier({ jwks: jwks(mine), issuer: ISS, audience: AUD })(token)).toBeNull()
50
+ })
51
+
52
+ it('rejects a wrong audience and a wrong issuer', async () => {
53
+ const key = await loadSigningKey((await freshKey()).pkcs8)
54
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
55
+ issuer: ISS, audience: 'other-realm', expiresInSeconds: 60,
56
+ })
57
+
58
+ expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token)).toBeNull()
59
+ expect(await verifier({ jwks: jwks(key), issuer: 'https://elsewhere.test', audience: 'other-realm' })(token)).toBeNull()
60
+ })
61
+
62
+ it('rejects an expired token', async () => {
63
+ const key = await loadSigningKey((await freshKey()).pkcs8)
64
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
65
+ issuer: ISS, audience: AUD, expiresInSeconds: -1,
66
+ })
67
+ expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token)).toBeNull()
68
+ })
69
+
70
+ it('keeps a retired key verifiable while it is still published', async () => {
71
+ const old = await loadSigningKey((await freshKey()).pkcs8)
72
+ const oldSpki = (await freshKey()).spki // ayri bir anahtar: yayinlanmamis olan
73
+ const current = await loadSigningKey((await freshKey()).pkcs8)
74
+
75
+ const tokenFromOld = await mint(old, { sub: 'usr_1', type: 'human' }, {
76
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
77
+ })
78
+
79
+ const withOld = verifier({ jwks: jwks(current, [old.publicJwk]), issuer: ISS, audience: AUD })
80
+ expect((await withOld(tokenFromOld))?.sub).toBe('usr_1')
81
+
82
+ // Yayindan dusunce ayni token artik gecmez.
83
+ const withoutOld = verifier({ jwks: jwks(current), issuer: ISS, audience: AUD })
84
+ expect(await withoutOld(tokenFromOld)).toBeNull()
85
+
86
+ // Emeklilikte yalniz public anahtar elde olur; PEM'den de yuklenebilmeli.
87
+ expect((await loadPublicKey(oldSpki)).kid).toBeTruthy()
88
+ })
89
+
90
+ it('accepts base64-wrapped PEM, because env vars eat newlines', async () => {
91
+ const { pkcs8 } = await freshKey()
92
+ const key = await loadSigningKey(Buffer.from(pkcs8).toString('base64'))
93
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
94
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
95
+ })
96
+ expect((await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token))?.sub).toBe('usr_1')
97
+ })
98
+ })
99
+
100
+ describe('HS256 migration mode', () => {
101
+ const secret = 'test-secret-32-chars-long-enough!'
102
+
103
+ it('accepts both an ES256 token and a legacy HS256 one', async () => {
104
+ const { signHs256Jwt } = await import('../jwt.js')
105
+ const key = await loadSigningKey((await freshKey()).pkcs8)
106
+ const verify = verifier({ jwks: jwks(key), issuer: ISS, audience: AUD, legacyHs256Secret: secret })
107
+
108
+ const modern = await mint(key, { sub: 'usr_1', type: 'human' }, {
109
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
110
+ })
111
+ const legacy = signHs256Jwt({ sub: 'usr_2', type: 'human' }, secret, 60)
112
+
113
+ expect((await verify(modern))?.sub).toBe('usr_1')
114
+ expect((await verify(legacy))?.sub).toBe('usr_2')
115
+ })
116
+
117
+ it('rejects a legacy token once the secret is withdrawn', async () => {
118
+ const { signHs256Jwt } = await import('../jwt.js')
119
+ const key = await loadSigningKey((await freshKey()).pkcs8)
120
+ const legacy = signHs256Jwt({ sub: 'usr_2', type: 'human' }, secret, 60)
121
+
122
+ expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(legacy)).toBeNull()
123
+ })
124
+ })
125
+
126
+ describe('hono middleware', () => {
127
+ const secret = 'test-secret-32-chars-long-enough!'
128
+
129
+ async function run(env: Record<string, string | undefined>, token?: string) {
130
+ const { requireAuth, resetAuthVerifier } = await import('../hono.js')
131
+ for (const [k, v] of Object.entries(env)) {
132
+ if (v === undefined) delete process.env[k]
133
+ else process.env[k] = v
134
+ }
135
+ resetAuthVerifier()
136
+
137
+ const { Hono } = await import('hono')
138
+ const app = new Hono()
139
+ app.use('*', requireAuth)
140
+ app.get('/', (c) => c.json(c.get('auth')))
141
+ return app.request('/', token ? { headers: { Authorization: `Bearer ${token}` } } : {})
142
+ }
143
+
144
+ it('answers 500, not 401, when nothing is configured', async () => {
145
+ const res = await run({ AUTH_JWKS_URI: undefined, JWT_SECRET: undefined }, 'whatever')
146
+ expect(res.status).toBe(500)
147
+ })
148
+
149
+ it('rejects a missing header', async () => {
150
+ expect((await run({ JWT_SECRET: secret })).status).toBe(401)
151
+ })
152
+
153
+ it('accepts a legacy HS256 token and classifies the token type', async () => {
154
+ const { signHs256Jwt } = await import('../jwt.js')
155
+ const res = await run(
156
+ { AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
157
+ signHs256Jwt({ sub: 'usr_1', type: 'service' }, secret, 60),
158
+ )
159
+ expect(res.status).toBe(200)
160
+ expect(await res.json()).toMatchObject({ userId: 'usr_1', tokenType: 'service' })
161
+ })
162
+
163
+ it('treats an unknown token type as a plain user token', async () => {
164
+ const { signHs256Jwt } = await import('../jwt.js')
165
+ const res = await run(
166
+ { AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
167
+ signHs256Jwt({ sub: 'usr_1', type: 'something-new' }, secret, 60),
168
+ )
169
+ expect(await res.json()).toMatchObject({ tokenType: 'user' })
170
+ })
171
+
172
+ it('rejects a token signed with a different secret', async () => {
173
+ const { signHs256Jwt } = await import('../jwt.js')
174
+ const res = await run(
175
+ { AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
176
+ signHs256Jwt({ sub: 'usr_1', type: 'human' }, 'a-completely-different-secret!!', 60),
177
+ )
178
+ expect(res.status).toBe(401)
179
+ })
180
+ })
181
+
182
+ describe('remote JWKS — the production path', () => {
183
+ it('fetches the key set over HTTP and verifies a token against it', async () => {
184
+ const { createServer } = await import('node:http')
185
+ const { exportPKCS8, generateKeyPair } = await import('jose')
186
+
187
+ const { privateKey } = await generateKeyPair('ES256', { extractable: true })
188
+ const key = await loadSigningKey(await exportPKCS8(privateKey))
189
+
190
+ let served = 0
191
+ const server = createServer((_req, res) => {
192
+ served += 1
193
+ res.writeHead(200, { 'content-type': 'application/json' })
194
+ res.end(JSON.stringify(jwks(key)))
195
+ })
196
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
197
+ const { port } = server.address() as { port: number }
198
+
199
+ try {
200
+ const verify = verifier({
201
+ jwksUri: `http://127.0.0.1:${port}/.well-known/jwks.json`,
202
+ issuer: ISS,
203
+ audience: AUD,
204
+ })
205
+
206
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
207
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
208
+ })
209
+
210
+ expect((await verify(token))?.sub).toBe('usr_1')
211
+ // Second call must come from cache: a fetch per request would put the
212
+ // auth service on the critical path of every request in the realm.
213
+ expect((await verify(token))?.sub).toBe('usr_1')
214
+ expect(served).toBe(1)
215
+ } finally {
216
+ await new Promise<void>((resolve) => server.close(() => resolve()))
217
+ }
218
+ })
219
+
220
+ it('returns null instead of throwing when the key set is unreachable', async () => {
221
+ const key = await loadSigningKey((await freshKey()).pkcs8)
222
+ const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
223
+ issuer: ISS, audience: AUD, expiresInSeconds: 60,
224
+ })
225
+
226
+ let seen: unknown
227
+ // Port 1 is reserved and never listening.
228
+ const verify = verifier({
229
+ jwksUri: 'http://127.0.0.1:1/jwks.json',
230
+ issuer: ISS,
231
+ audience: AUD,
232
+ onError: (err) => { seen = err },
233
+ })
234
+
235
+ expect(await verify(token)).toBeNull()
236
+ expect(seen).toBeTruthy()
237
+ })
238
+ })
package/src/cli-auth.ts CHANGED
@@ -20,7 +20,13 @@ export interface CliAuthResult {
20
20
  export interface PollOptions {
21
21
  /** ms between polls (default: 2000) */
22
22
  intervalMs?: number;
23
- /** ms before giving up (default: 300_000 = 5 min) */
23
+ /**
24
+ * ms before giving up (default: 600_000 = 10 min).
25
+ *
26
+ * Prefer passing `start.expiresIn * 1000` so the CLI stops at the same moment
27
+ * the server drops the state; a shorter timeout reports a client-side
28
+ * giving-up while the link is in fact still good.
29
+ */
24
30
  timeoutMs?: number;
25
31
  }
26
32
 
@@ -64,7 +70,7 @@ export async function pollCliAuth(
64
70
  opts: PollOptions = {},
65
71
  ): Promise<CliAuthResult> {
66
72
  const intervalMs = opts.intervalMs ?? 2_000;
67
- const timeoutMs = opts.timeoutMs ?? 300_000;
73
+ const timeoutMs = opts.timeoutMs ?? 600_000;
68
74
  const base = apiBase.replace(/\/+$/, '');
69
75
  const deadline = Date.now() + timeoutMs;
70
76
 
@@ -86,14 +92,17 @@ export async function pollCliAuth(
86
92
  return { token: data.token };
87
93
  }
88
94
 
95
+ // The server answers 'expired' both for a state whose TTL ran out and for
96
+ // one it never had; from here the two are the same thing — nobody approved
97
+ // in time.
89
98
  if (data.status === 'expired') {
90
- throw new Error('Auth session expired — run login again');
99
+ throw new Error('the sign-in was not approved in time — run login again');
91
100
  }
92
101
 
93
102
  // status === "pending" → continue polling
94
103
  }
95
104
 
96
- throw new Error('Auth timed out — run login again');
105
+ throw new Error('no approval received in time — run login again');
97
106
  }
98
107
 
99
108
  // ─── helpers ────────────────────────────────────────────────────────────────