@baseworks/auth 0.2.7 → 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/README.md CHANGED
@@ -1,31 +1,251 @@
1
1
  # @baseworks/auth
2
2
 
3
- Authentication utilities for the baseworks monorepo. Uses subpath imports so each environment only pulls in what it needs.
3
+ Identity for a baseworks realm: verifying tokens, issuing them, and the sign-in
4
+ commands a CLI needs.
4
5
 
5
- ## Subpath Imports
6
+ A **realm** is one deployment of the plane — one auth-service, one signing key
7
+ pair, and the services that trust it. A token issued in a realm is accepted by
8
+ every service in it and by no other.
9
+
10
+ ```
11
+ npm i @baseworks/auth
12
+ ```
13
+
14
+ Subpath imports, so a Worker or a browser bundle never pulls in Node-only code.
15
+ The task-oriented guide is below; a per-file reference follows it.
16
+
17
+ ---
18
+
19
+ ## I have a service that needs to verify tokens
20
+
21
+ The common case, and usually one line:
22
+
23
+ ```ts
24
+ import { requireAuth } from '@baseworks/auth/hono'
25
+
26
+ app.use('*', requireAuth)
27
+ app.get('/things', (c) => {
28
+ const { userId, tokenType } = c.get('auth')
29
+ …
30
+ })
31
+ ```
32
+
33
+ Configured entirely from the environment, so the code above never changes:
34
+
35
+ | Variable | Meaning |
36
+ |---|---|
37
+ | `AUTH_JWKS_URI` | `https://auth.example.com/.well-known/jwks.json` |
38
+ | `AUTH_ISSUER` | Expected `iss`. Unset means "do not check" |
39
+ | `AUTH_AUDIENCE` | Expected `aud`. Unset means "do not check" |
40
+ | `JWT_SECRET` | Legacy HS256 secret, accepted alongside ES256 while set |
41
+
42
+ Set both `AUTH_JWKS_URI` and `JWT_SECRET` during a migration and both kinds of
43
+ token are accepted. With only `JWT_SECRET`, behaviour is exactly what it was
44
+ before JWKS existed — so a service can be upgraded before the realm has keys.
45
+
46
+ **Set `AUTH_ISSUER` and `AUTH_AUDIENCE` in production.** Without them, a token
47
+ from any realm whose key set you happen to fetch will verify.
48
+
49
+ A service with neither a JWKS URI nor a secret answers **500, not 401**: a
50
+ misconfigured service must not look like a rejected caller, or whoever is paged
51
+ goes looking at the user's credentials.
52
+
53
+ Not using Hono? Build the verifier directly:
54
+
55
+ ```ts
56
+ import { verifier } from '@baseworks/auth/identity'
57
+
58
+ const verify = verifier({
59
+ jwksUri: process.env.AUTH_JWKS_URI,
60
+ issuer: process.env.AUTH_ISSUER,
61
+ audience: process.env.AUTH_AUDIENCE,
62
+ })
63
+
64
+ const claims = await verify(token) // null when the token is not acceptable
65
+ ```
66
+
67
+ Build it **once per process**. It owns the JWKS cache; rebuilding per request
68
+ refetches the key set every time and puts auth-service on the critical path of
69
+ every request in the realm.
70
+
71
+ `verify` returns `null` instead of throwing, so a request handler has one 401
72
+ path rather than a taxonomy of crypto errors. Pass `onError` to log the reason.
73
+
74
+ ### What is in a token
75
+
76
+ ```
77
+ iss aud sub iat exp jti type: human | service | assistant
78
+ ```
79
+
80
+ **No organisation, no role.** Membership changes while a token lives, so a token
81
+ carrying a role goes stale and lies. Ask the org service and cache the answer for
82
+ a minute; that is the only way it can be current.
83
+
84
+ `type` matters for authorisation: `service` and `assistant` open doors a plain
85
+ user token must not. `requireAuth` maps anything unrecognised to `user`, the
86
+ least privileged reading.
87
+
88
+ ---
89
+
90
+ ## I am the issuer
91
+
92
+ Only auth-service does this. The private key never leaves it, which is exactly
93
+ why every other service can be trusted with the public half and nothing else.
94
+
95
+ ```ts
96
+ import { loadSigningKey, jwks, mint } from '@baseworks/auth/identity'
97
+
98
+ const key = await loadSigningKey(process.env.AUTH_SIGNING_KEY!)
99
+
100
+ app.get('/.well-known/jwks.json', (c) => c.json(jwks(key)))
101
+
102
+ const token = await mint(key, { sub: userId, type: 'human' }, {
103
+ issuer: 'https://auth.example.com',
104
+ audience: 'example-realm',
105
+ expiresInSeconds: 86400,
106
+ })
107
+ ```
108
+
109
+ `loadSigningKey` takes a PKCS#8 PEM **or base64 of one**, because environment
110
+ variables, Kubernetes Secrets and CI variables all mangle the newlines a PEM
111
+ needs. The key id is the RFC 7638 thumbprint of the key itself, so it is
112
+ identical everywhere and never needs configuring.
113
+
114
+ `jwks(key, retired)` publishes retired public keys alongside the current one.
115
+ That is how rotation works: publish both for longer than a token lives, sign with
116
+ the new key, drop the old one when its tokens have expired.
117
+
118
+ ---
119
+
120
+ ## I am building a CLI
121
+
122
+ ```ts
123
+ import { addAuthCommands } from '@baseworks/auth/cli'
124
+
125
+ addAuthCommands(program, {
126
+ cliName: 'mycli',
127
+ authBase: () => 'https://auth.example.com',
128
+ onToken: (token) => config.patch({ token }),
129
+ onLogout: () => config.patch({ token: undefined }),
130
+ getToken: () => config.load().token,
131
+ tokenOrigin: () => config.writePath,
132
+ statusRows: () => [['url', apiBase()]],
133
+ nextSteps: 'mycli whoami',
134
+ })
135
+ ```
136
+
137
+ The user gets three commands:
138
+
139
+ ```
140
+ mycli login sign in through the browser
141
+ mycli logout forget the saved token
142
+ mycli auth status who am I, and how long is this token good for
143
+ ```
144
+
145
+ ```
146
+ $ mycli auth status
147
+ url https://example.com
148
+ user 01a05840-b6aa-7421-80d7-6eb192551584
149
+ token type human
150
+ expires 2026-09-13 14:33 UTC (in 23h 57m)
151
+ token from /Users/me/.mycli/config.toml
152
+ ```
153
+
154
+ Storage stays yours — this module never touches the filesystem. Pass `getToken`
155
+ and `auth status` appears; leave it out and it does not. `logout` warns when a
156
+ token is still reachable afterwards, which is what happens when an environment
157
+ variable outranks the config file.
158
+
159
+ Use [`@baseworks/config`](../baseworks-config) for the file, with
160
+ `scope: 'global'`: it writes `0600` under `~/.<app>/`. A project-local config
161
+ sits inside a working tree, and a token there gets committed.
162
+
163
+ ### Driving the flow yourself
164
+
165
+ ```ts
166
+ import { startCliAuth, pollCliAuth } from '@baseworks/auth/cli-auth'
167
+
168
+ const start = await startCliAuth(base)
169
+ console.log(`Approve at: ${start.url}`)
170
+ const { token } = await pollCliAuth(base, start.state, {
171
+ timeoutMs: start.expiresIn * 1000,
172
+ })
173
+ ```
174
+
175
+ **Poll until exactly `expiresIn`, no sooner.** A shorter client timeout reports
176
+ failure while the link still works — the default used to be five minutes against
177
+ a ten-minute handle, which produced exactly that bug.
178
+
179
+ ---
180
+
181
+ ## Migrating off a shared HMAC secret
182
+
183
+ A shared secret does not merely let a service *check* tokens, it lets that
184
+ service **issue** them — including a token claiming to be another user. Not
185
+ hypothetical: a product API in this plane was minting
186
+ `{ sub: <another user>, type: 'user' }` to act on that user's behalf.
187
+
188
+ Each step is safe on its own; the power actually goes away at step 5.
189
+
190
+ 1. Deploy auth-service **without** a signing key. It keeps issuing HS256.
191
+ 2. Upgrade verifiers to this version and give them `AUTH_JWKS_URI`. Both
192
+ algorithms are accepted now.
193
+ 3. Give auth-service `AUTH_SIGNING_KEY`. New tokens are ES256; existing ones keep
194
+ verifying until they expire.
195
+ 4. Move anything that was *minting* onto `POST /service-token` (a service acting
196
+ as itself) or `POST /exchange` (a narrowed copy of the caller's own token).
197
+ There is deliberately no replacement for minting as an arbitrary user — grant
198
+ the service the permission instead.
199
+ 5. Remove `JWT_SECRET` everywhere.
200
+
201
+ ---
202
+
203
+ ## Subpaths
6
204
 
7
205
  | Import | Runtime | Contents |
8
- |--------|---------|----------|
9
- | `@baseworks/auth` | Any | token helpers, verifyHs256Jwt, verifyOidcToken, pkce, cli-auth, url-helpers |
10
- | `@baseworks/auth/jwt` | Node.js | HS256 JWT verification |
11
- | `@baseworks/auth/hono` | Node.js + Hono | `requireAuth` middleware |
12
- | `@baseworks/auth/oidc` | Workers / Node / Browser | OIDC token verification via JWKS |
13
- | `@baseworks/auth/oidc-human` | Any | OIDC → DB user resolver factory |
14
- | `@baseworks/auth/pkce` | Workers / Node / Browser | PKCE generation + OIDC auth URL builder |
15
- | `@baseworks/auth/token` | Workers / Node / Browser | hashToken, looksLikeJwt, stripBearer |
16
- | `@baseworks/auth/cli-auth` | Node.js CLI | Browser-based polling auth flow |
17
- | `@baseworks/auth/session` | Next.js (Server) | OIDC cookie session, token exchange, refresh |
18
- | `@baseworks/auth/edge` | Next.js (Edge/Server) | Pomerium JWT assertions + cookie fallback |
19
- | `@baseworks/auth/url-helpers` | Next.js | Auth/account public URL builders |
206
+ |---|---|---|
207
+ | `@baseworks/auth/identity` | Node | **Realm tokens: `mint`, `jwks`, `verifier`** |
208
+ | `@baseworks/auth/hono` | Node + Hono | `requireAuth` middleware |
209
+ | `@baseworks/auth/cli` | Node CLI | `login` / `logout` / `auth status` commands |
210
+ | `@baseworks/auth/cli-auth` | Node CLI | The device flow, without the commands |
211
+ | `@baseworks/auth/jwt` | Node | HS256 sign/verify, `parseJwtPayload`. Legacy |
212
+ | `@baseworks/auth/oidc` | Any | OIDC token verification via JWKS |
213
+ | `@baseworks/auth/oidc-human` | Any | OIDC → user resolver factory |
214
+ | `@baseworks/auth/pkce` | Any | PKCE generation, OIDC auth URL builder |
215
+ | `@baseworks/auth/token` | Any | `hashToken`, `looksLikeJwt`, `stripBearer` |
216
+
217
+ `hono` is an optional peer dependency: only `@baseworks/auth/hono` pulls it in.
218
+
219
+ There is no browser-session subpath. Up to 0.3 the package carried a Next.js
220
+ sign-in (`session`, `edge`, `url-helpers`); it trusted an unsigned cookie and an
221
+ unverified id_token, and nothing used it. Signing a person in is auth-service's
222
+ job — an app sends the browser to `/v1/auth/login` and reads `/v1/auth/session`.
223
+
224
+ ## Known gaps
225
+
226
+ - **No revocation.** A token is valid until it expires. Every token carries a
227
+ `jti`, so a deny list can be added without a format change — but none exists.
228
+ - **`@baseworks/auth/jwt` still exports `signHs256Jwt`.** That is the shared
229
+ secret era. Reach for `identity`; this stays only until the last consumer has
230
+ migrated.
231
+
232
+ ## Development
233
+
234
+ ```
235
+ pnpm test # vitest
236
+ pnpm build # tsup
237
+ pnpm typecheck
238
+ ```
20
239
 
21
240
  ---
22
241
 
23
- ## File Reference
242
+ # Per-file reference
243
+
24
244
 
25
245
  ### `jwt.ts` → `@baseworks/auth/jwt`
26
246
  **Runtime:** Node.js — uses `node:crypto`
27
247
 
28
- HS256 JWT verification. Used by Flect services (`org-service`, `account-service`) to verify service-to-service JWTs signed with `JWT_SECRET`.
248
+ HS256 sign and verify, plus `parseJwtPayload`. **Legacy** — the shared-secret era. New code uses `@baseworks/auth/identity`; this remains until the last consumer migrates.
29
249
 
30
250
  ```ts
31
251
  import { verifyHs256Jwt } from '@baseworks/auth/jwt'
@@ -39,7 +259,7 @@ const claims = verifyHs256Jwt(token, process.env.JWT_SECRET)
39
259
  ### `hono.ts` → `@baseworks/auth/hono`
40
260
  **Runtime:** Node.js + Hono — peer dep: `hono`
41
261
 
42
- Hono middleware. Reads `Authorization: Bearer <token>`, verifies with `jwt.ts`, sets `c.var.auth` (typed as `ServiceAuthUser`).
262
+ Hono middleware. Reads `Authorization: Bearer <token>`, verifies against the realm JWKS (and the legacy secret while one is set), sets `c.var.auth` (typed as `ServiceAuthUser`).
43
263
 
44
264
  ```ts
45
265
  import { requireAuth } from '@baseworks/auth/hono'
@@ -137,71 +357,12 @@ const { token } = await pollCliAuth('https://api.flect.run', state)
137
357
 
138
358
  ---
139
359
 
140
- ### `session.ts` → `@baseworks/auth/session`
141
- **Runtime:** Next.js (Server Components, Route Handlers) — dep: `next`
142
-
143
- Full OIDC PKCE session management for Next.js. Handles cookie set/clear, authorization code exchange, and token refresh. Uses `@baseworks/core` for base64url encoding and `pkce.ts` for PKCE generation.
144
-
145
- ```ts
146
- import {
147
- buildAuthorizationRedirect,
148
- handleAuthorizationCallback,
149
- getSessionFromCookies,
150
- getServerAccessToken,
151
- buildLogoutResponse,
152
- } from '@baseworks/auth/session'
153
- ```
154
-
155
- Reads env vars: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (optional).
156
-
157
- ---
158
-
159
- ### `edge.ts` → `@baseworks/auth/edge`
160
- **Runtime:** Next.js (Middleware, Server Components) — dep: `next`
161
-
162
- Reads the session from two sources in priority order: **Pomerium JWT assertion headers** (`x-pomerium-jwt-assertion`) first, then **OIDC cookie** fallback. Returns an `EdgeSession`.
163
-
164
- ```ts
165
- import { getEdgeSession } from '@baseworks/auth/edge'
166
-
167
- const session = await getEdgeSession()
168
- // session.provider: 'pomerium' | 'cookie' | 'anonymous'
169
- ```
170
-
171
- ---
172
-
173
- ### `url-helpers.ts` → `@baseworks/auth/url-helpers`
174
- **Runtime:** Next.js — dep: none
175
-
176
- Reads Next.js env vars and exposes URL builder functions.
177
-
178
- ```ts
179
- import { buildAuthLoginUrl, buildAuthLogoutUrl } from '@baseworks/auth/url-helpers'
180
-
181
- const loginUrl = buildAuthLoginUrl('/dashboard')
182
- const logoutUrl = buildAuthLogoutUrl('/')
183
- ```
184
-
185
- Env vars read: `NEXT_PUBLIC_AUTH_URL`, `NEXT_PUBLIC_ACCOUNT_URL`, `APP_PUBLIC_URL`, `NEXT_PUBLIC_SITE_URL`.
186
-
187
- ---
188
-
189
- ### `_internal.ts` — not exported
190
- Shared internal helpers. Not part of the public API.
191
-
192
- - `parseJwtPayload(token)` — splits a JWT and decodes the payload using `@baseworks/core/codec`. Used by `session.ts` and `edge.ts`.
193
-
194
- ---
195
-
196
360
  ## Internal Dependencies
197
361
 
198
362
  ```
199
363
  @baseworks/core/codec
200
364
  └── base64urlEncode / base64urlDecodeString
201
- ├── pkce.ts (PKCE verifier/challenge generation)
202
- ├── session.ts (cookie encode/decode)
203
- └── _internal.ts (parseJwtPayload)
204
- └── edge.ts
365
+ └── pkce.ts (PKCE verifier/challenge generation)
205
366
  ```
206
367
 
207
368
  ## When to Use What
@@ -210,7 +371,13 @@ Shared internal helpers. Not part of the public API.
210
371
  Service-to-service JWT verification (Hono/Node)
211
372
  └─ @baseworks/auth/hono → requireAuth middleware
212
373
 
213
- Raw HS256 JWT verify
374
+ Mint a realm token / publish a JWKS (auth-service only)
375
+ └─ @baseworks/auth/identity → mint / jwks / loadSigningKey
376
+
377
+ Verify without Hono
378
+ └─ @baseworks/auth/identity → verifier
379
+
380
+ Raw HS256 JWT verify (legacy)
214
381
  └─ @baseworks/auth/jwt → verifyHs256Jwt
215
382
 
216
383
  User OIDC token verification (Workers/Node)
@@ -222,12 +389,10 @@ OIDC token + DB user sync in one step
222
389
  API key hashing / Bearer header parsing
223
390
  └─ @baseworks/auth/token → hashToken / stripBearer
224
391
 
225
- Next.js OIDC login / callback / logout
226
- └─ @baseworks/auth/session → buildAuthorizationRedirect / handleAuthorizationCallback
227
-
228
- Who is this user in Next.js Middleware?
229
- └─ @baseworks/auth/edge → getEdgeSession
392
+ Sign a person in from a web app
393
+ └─ not this package → redirect to auth-service /v1/auth/login
230
394
 
231
395
  CLI browser auth
232
- └─ @baseworks/auth/cli-auth → startCliAuth / pollCliAuth
396
+ └─ @baseworks/auth/cli → login / logout / auth status commands
397
+ └─ @baseworks/auth/cli-auth → startCliAuth / pollCliAuth (raw flow)
233
398
  ```
@@ -0,0 +1,88 @@
1
+ // src/identity.ts
2
+ import {
3
+ SignJWT,
4
+ jwtVerify,
5
+ exportJWK,
6
+ importPKCS8,
7
+ importSPKI,
8
+ calculateJwkThumbprint,
9
+ createLocalJWKSet,
10
+ createRemoteJWKSet
11
+ } from "jose";
12
+ var IDENTITY_ALG = "ES256";
13
+ function toPem(raw, label) {
14
+ const value = raw.trim();
15
+ if (value.includes("-----BEGIN")) return value.replace(/\\n/g, "\n");
16
+ const decoded = Buffer.from(value, "base64").toString("utf8");
17
+ if (decoded.includes("-----BEGIN")) return decoded.trim();
18
+ throw new Error(`not a ${label} key: expected PEM, or base64 of PEM`);
19
+ }
20
+ async function jwkOf(key, kid) {
21
+ const jwk = await exportJWK(key);
22
+ jwk.alg = IDENTITY_ALG;
23
+ jwk.use = "sig";
24
+ jwk.kid = kid ?? await calculateJwkThumbprint(jwk);
25
+ return jwk;
26
+ }
27
+ async function loadSigningKey(privateKeyPem, kid) {
28
+ const pem = toPem(privateKeyPem, "PRIVATE");
29
+ const privateKey = await importPKCS8(pem, IDENTITY_ALG, { extractable: true });
30
+ const publicJwk = await jwkOf(privateKey, kid);
31
+ delete publicJwk.d;
32
+ return { privateKey, kid: publicJwk.kid, publicJwk };
33
+ }
34
+ async function loadPublicKey(publicKeyPem, kid) {
35
+ const key = await importSPKI(toPem(publicKeyPem, "PUBLIC"), IDENTITY_ALG, { extractable: true });
36
+ return jwkOf(key, kid);
37
+ }
38
+ function jwks(signing, retired = []) {
39
+ return { keys: [signing.publicJwk, ...retired] };
40
+ }
41
+ async function mint(key, claims, opts) {
42
+ const { sub, type, ...rest } = claims;
43
+ return new SignJWT({ type, ...rest }).setProtectedHeader({ alg: IDENTITY_ALG, kid: key.kid }).setSubject(sub).setIssuer(opts.issuer).setAudience(opts.audience).setIssuedAt().setJti(crypto.randomUUID()).setExpirationTime(`${opts.expiresInSeconds}s`).sign(key.privateKey);
44
+ }
45
+ function verifier(opts) {
46
+ if (!opts.jwksUri && !opts.jwks && !opts.legacyHs256Secret) {
47
+ throw new Error("verifier needs jwksUri, jwks, or legacyHs256Secret");
48
+ }
49
+ const keys = opts.jwks ? createLocalJWKSet(opts.jwks) : opts.jwksUri ? createRemoteJWKSet(new URL(opts.jwksUri)) : void 0;
50
+ const legacy = opts.legacyHs256Secret ? new TextEncoder().encode(opts.legacyHs256Secret) : void 0;
51
+ const expectations = {
52
+ ...opts.issuer ? { issuer: opts.issuer } : {},
53
+ ...opts.audience ? { audience: opts.audience } : {}
54
+ };
55
+ return async (token) => {
56
+ if (keys) {
57
+ try {
58
+ const { payload } = await jwtVerify(token, keys, {
59
+ ...expectations,
60
+ algorithms: [IDENTITY_ALG]
61
+ });
62
+ return payload;
63
+ } catch (err) {
64
+ if (!legacy) {
65
+ opts.onError?.(err);
66
+ return null;
67
+ }
68
+ }
69
+ }
70
+ if (!legacy) return null;
71
+ try {
72
+ const { payload } = await jwtVerify(token, legacy, { algorithms: ["HS256"] });
73
+ return payload;
74
+ } catch (err) {
75
+ opts.onError?.(err);
76
+ return null;
77
+ }
78
+ };
79
+ }
80
+
81
+ export {
82
+ IDENTITY_ALG,
83
+ loadSigningKey,
84
+ loadPublicKey,
85
+ jwks,
86
+ mint,
87
+ verifier
88
+ };
@@ -18,7 +18,7 @@ async function startCliAuth(apiBase) {
18
18
  }
19
19
  async function pollCliAuth(apiBase, state, opts = {}) {
20
20
  const intervalMs = opts.intervalMs ?? 2e3;
21
- const timeoutMs = opts.timeoutMs ?? 3e5;
21
+ const timeoutMs = opts.timeoutMs ?? 6e5;
22
22
  const base = apiBase.replace(/\/+$/, "");
23
23
  const deadline = Date.now() + timeoutMs;
24
24
  while (Date.now() < deadline) {
@@ -32,10 +32,10 @@ async function pollCliAuth(apiBase, state, opts = {}) {
32
32
  return { token: data.token };
33
33
  }
34
34
  if (data.status === "expired") {
35
- throw new Error("Auth session expired \u2014 run login again");
35
+ throw new Error("the sign-in was not approved in time \u2014 run login again");
36
36
  }
37
37
  }
38
- throw new Error("Auth timed out \u2014 run login again");
38
+ throw new Error("no approval received in time \u2014 run login again");
39
39
  }
40
40
  function sleep(ms) {
41
41
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -17,7 +17,13 @@ interface CliAuthResult {
17
17
  interface PollOptions {
18
18
  /** ms between polls (default: 2000) */
19
19
  intervalMs?: number;
20
- /** ms before giving up (default: 300_000 = 5 min) */
20
+ /**
21
+ * ms before giving up (default: 600_000 = 10 min).
22
+ *
23
+ * Prefer passing `start.expiresIn * 1000` so the CLI stops at the same moment
24
+ * the server drops the state; a shorter timeout reports a client-side
25
+ * giving-up while the link is in fact still good.
26
+ */
21
27
  timeoutMs?: number;
22
28
  }
23
29
  /**
package/dist/cli-auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  pollCliAuth,
3
3
  startCliAuth
4
- } from "./chunk-C4V5LCFA.js";
4
+ } from "./chunk-GXDYEKX7.js";
5
5
  export {
6
6
  pollCliAuth,
7
7
  startCliAuth
package/dist/cli.d.ts CHANGED
@@ -10,7 +10,21 @@ interface AuthCliDeps {
10
10
  onLogout?: () => void | Promise<void>;
11
11
  /** Open the browser automatically (default true). */
12
12
  openBrowser?: boolean;
13
+ /**
14
+ * Read the saved identity token. Providing it registers `<cli> auth status`,
15
+ * and lets `logout` warn when a token is still reachable afterwards — an
16
+ * environment variable that outranks the config file, typically.
17
+ */
18
+ getToken?: () => string | undefined;
19
+ /** Where the token came from, for display: a config path, or an env var name. */
20
+ tokenOrigin?: () => string | undefined;
21
+ /** Extra rows for `auth status`, e.g. the API base URL the app will call. */
22
+ statusRows?: () => [string, string][];
23
+ /** One-line hint printed after a successful login. */
24
+ nextSteps?: string;
13
25
  }
26
+ /** `<cli> auth` and `<cli> auth status` — what the saved token says, no network. */
27
+ declare function buildAuthCommand(deps: AuthCliDeps): Command;
14
28
  declare function addAuthCommands(program: Command, deps: AuthCliDeps): void;
15
29
 
16
- export { type AuthCliDeps, addAuthCommands };
30
+ export { type AuthCliDeps, addAuthCommands, buildAuthCommand };
package/dist/cli.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  pollCliAuth,
3
3
  startCliAuth
4
- } from "./chunk-C4V5LCFA.js";
4
+ } from "./chunk-GXDYEKX7.js";
5
+ import {
6
+ parseJwtPayload
7
+ } from "./chunk-OD5XLL3O.js";
5
8
 
6
9
  // src/cli.ts
7
10
  import { spawn } from "child_process";
8
11
  import { Command } from "commander";
9
12
  import { clr, kv, success, warn, fatal } from "@baseworks/cli/display";
13
+ import { fmtRelative } from "@baseworks/cli/fmt";
10
14
  function resolveBase(b) {
11
15
  return (typeof b === "function" ? b() : b).replace(/\/+$/, "");
12
16
  }
@@ -18,15 +22,37 @@ function openInBrowser(url) {
18
22
  } catch {
19
23
  }
20
24
  }
21
- function jwtSub(token) {
22
- try {
23
- const seg = token.split(".")[1];
24
- if (!seg) return void 0;
25
- const json = Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
26
- return JSON.parse(json).sub;
27
- } catch {
28
- return void 0;
29
- }
25
+ function claimsOf(token) {
26
+ return parseJwtPayload(token) ?? {};
27
+ }
28
+ function expiryLine(claims) {
29
+ if (!claims.exp) return "unknown";
30
+ const at = new Date(claims.exp * 1e3).toISOString().replace("T", " ").slice(0, 16);
31
+ return `${at} UTC (${fmtRelative(claims.exp)})`;
32
+ }
33
+ function buildAuthCommand(deps) {
34
+ const cli = deps.cliName ?? "cli";
35
+ const status = () => {
36
+ const token = deps.getToken?.();
37
+ if (!token) fatal(`not signed in \u2014 run \`${cli} login\``);
38
+ const claims = claimsOf(token);
39
+ const rows = [...deps.statusRows?.() ?? []];
40
+ rows.push(["user", claims.sub ?? "unknown"]);
41
+ rows.push(["token type", claims.type ?? "unknown"]);
42
+ if (claims.iss) rows.push(["issuer", claims.iss]);
43
+ rows.push(["expires", expiryLine(claims)]);
44
+ const origin = deps.tokenOrigin?.();
45
+ if (origin) rows.push(["token from", origin]);
46
+ kv(rows);
47
+ if (claims.exp && claims.exp * 1e3 <= Date.now()) {
48
+ console.log("");
49
+ fatal(`the token has expired \u2014 run \`${cli} login\``);
50
+ }
51
+ console.log("");
52
+ };
53
+ const cmd = new Command("auth").description("Show the current sign-in (alias: auth status)").action(status);
54
+ cmd.addCommand(new Command("status").description("Show the current sign-in").action(status));
55
+ return cmd;
30
56
  }
31
57
  function addAuthCommands(program, deps) {
32
58
  const cli = deps.cliName ?? "cli";
@@ -40,12 +66,17 @@ function addAuthCommands(program, deps) {
40
66
  ${clr.cyan}${start.url}${clr.reset}
41
67
  `);
42
68
  if (deps.openBrowser !== false && opts.open !== false) openInBrowser(start.url);
43
- const { token } = await pollCliAuth(base, start.state).catch((e) => fatal(e.message));
69
+ const minutes = Math.max(1, Math.round(start.expiresIn / 60));
70
+ console.log(` ${clr.dim}Waiting for approval \u2014 the link is valid for ${minutes}m\u2026${clr.reset}`);
71
+ const { token } = await pollCliAuth(base, start.state, { timeoutMs: start.expiresIn * 1e3 }).catch((e) => fatal(e.message));
44
72
  await deps.onToken(token);
45
- const sub = jwtSub(token);
73
+ const claims = claimsOf(token);
46
74
  success("Logged in.");
47
- if (sub) kv([["user id", sub]]);
48
- console.log(` ${clr.dim}Next: ${cli} use \xB7 ${cli} orgs${clr.reset}
75
+ const rows = [];
76
+ if (claims.sub) rows.push(["user", claims.sub]);
77
+ if (claims.exp) rows.push(["expires", expiryLine(claims)]);
78
+ if (rows.length) kv(rows);
79
+ console.log(` ${clr.dim}Next: ${deps.nextSteps ?? `${cli} use \xB7 ${cli} orgs`}${clr.reset}
49
80
  `);
50
81
  })
51
82
  );
@@ -54,9 +85,20 @@ function addAuthCommands(program, deps) {
54
85
  if (deps.onLogout) await deps.onLogout();
55
86
  else warn("No logout handler configured.");
56
87
  success("Logged out.");
88
+ const leftover = deps.getToken?.();
89
+ if (leftover) {
90
+ const origin = deps.tokenOrigin?.();
91
+ warn(`A token is still in effect${origin ? ` (${origin})` : ""} and takes precedence.`);
92
+ }
93
+ console.log(
94
+ ` ${clr.dim}The token stays valid on the server until it expires; there is no server-side revocation.${clr.reset}
95
+ `
96
+ );
57
97
  })
58
98
  );
99
+ if (deps.getToken) program.addCommand(buildAuthCommand(deps));
59
100
  }
60
101
  export {
61
- addAuthCommands
102
+ addAuthCommands,
103
+ buildAuthCommand
62
104
  };