@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 +249 -84
- package/dist/chunk-3XJIAYEG.js +88 -0
- package/dist/{chunk-C4V5LCFA.js → chunk-GXDYEKX7.js} +3 -3
- package/dist/cli-auth.d.ts +7 -1
- package/dist/cli-auth.js +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +57 -15
- package/dist/hono.d.ts +11 -5
- package/dist/hono.js +41 -14
- package/dist/identity.d.ts +95 -0
- package/dist/identity.js +16 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +5 -25
- package/package.json +15 -19
- package/src/__tests__/identity.test.ts +238 -0
- package/src/cli-auth.ts +13 -4
- package/src/cli.ts +103 -16
- package/src/hono.ts +68 -16
- package/src/identity.ts +224 -0
- package/src/index.ts +3 -17
- package/dist/chunk-3NJASEF4.js +0 -69
- package/dist/chunk-JS5Y2DTW.js +0 -387
- package/dist/edge.d.ts +0 -17
- package/dist/edge.js +0 -145
- package/dist/session.d.ts +0 -42
- package/dist/session.js +0 -27
- package/dist/url-helpers.d.ts +0 -11
- package/dist/url-helpers.js +0 -22
- package/src/edge.ts +0 -174
- package/src/session.ts +0 -512
- package/src/url-helpers.ts +0 -66
package/README.md
CHANGED
|
@@ -1,31 +1,251 @@
|
|
|
1
1
|
# @baseworks/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Identity for a baseworks realm: verifying tokens, issuing them, and the sign-in
|
|
4
|
+
commands a CLI needs.
|
|
4
5
|
|
|
5
|
-
|
|
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` |
|
|
10
|
-
| `@baseworks/auth/
|
|
11
|
-
| `@baseworks/auth/
|
|
12
|
-
| `@baseworks/auth/
|
|
13
|
-
| `@baseworks/auth/
|
|
14
|
-
| `@baseworks/auth/
|
|
15
|
-
| `@baseworks/auth/
|
|
16
|
-
| `@baseworks/auth/
|
|
17
|
-
| `@baseworks/auth/
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
└─
|
|
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
|
|
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 ??
|
|
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("
|
|
35
|
+
throw new Error("the sign-in was not approved in time \u2014 run login again");
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
throw new Error("
|
|
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));
|
package/dist/cli-auth.d.ts
CHANGED
|
@@ -17,7 +17,13 @@ interface CliAuthResult {
|
|
|
17
17
|
interface PollOptions {
|
|
18
18
|
/** ms between polls (default: 2000) */
|
|
19
19
|
intervalMs?: number;
|
|
20
|
-
/**
|
|
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
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-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
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
|
|
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
|
|
73
|
+
const claims = claimsOf(token);
|
|
46
74
|
success("Logged in.");
|
|
47
|
-
|
|
48
|
-
|
|
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
|
};
|