@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/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 +12 -5
- package/dist/hono.js +41 -13
- 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/dist/jwt.d.ts +1 -0
- 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 +69 -15
- package/src/identity.ts +224 -0
- package/src/index.ts +3 -17
- package/src/jwt.ts +1 -0
- 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/src/cli.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Importable `login` / `logout` commands for any CLI that
|
|
3
|
-
* an auth-service (OIDC/PKCE device flow). Mirrors the
|
|
4
|
-
* `@baseworks/iam/cli` pattern — the commands live in
|
|
5
|
-
* app composes them:
|
|
2
|
+
* Importable `login` / `logout` / `auth status` commands for any CLI that
|
|
3
|
+
* authenticates through an auth-service (OIDC/PKCE device flow). Mirrors the
|
|
4
|
+
* `@baseworks/org/cli` and `@baseworks/iam/cli` pattern — the commands live in
|
|
5
|
+
* auth's own package; the app composes them:
|
|
6
6
|
*
|
|
7
7
|
* import { addAuthCommands } from '@baseworks/auth/cli'
|
|
8
8
|
* addAuthCommands(program, { authBase, cliName: 'dtab', onToken, onLogout })
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* HS256) that org-service and iam-service verify with the shared realm secret —
|
|
13
13
|
* so ONE login token works across the whole plane. Org context is not in the
|
|
14
14
|
* token; the app resolves it via org-service (`use` / `ls`).
|
|
15
|
+
*
|
|
16
|
+
* Storage stays with the app: this module never touches the filesystem. Pass
|
|
17
|
+
* `onToken` / `onLogout` to persist, and `getToken` to enable `auth status`.
|
|
15
18
|
*/
|
|
16
19
|
import { spawn } from 'node:child_process'
|
|
17
20
|
import { Command } from 'commander'
|
|
18
21
|
import { clr, kv, success, warn, fatal } from '@baseworks/cli/display'
|
|
22
|
+
import { fmtRelative } from '@baseworks/cli/fmt'
|
|
23
|
+
import { parseJwtPayload } from './jwt.js'
|
|
19
24
|
import { startCliAuth, pollCliAuth } from './cli-auth.js'
|
|
20
25
|
|
|
21
26
|
export interface AuthCliDeps {
|
|
@@ -28,6 +33,18 @@ export interface AuthCliDeps {
|
|
|
28
33
|
onLogout?: () => void | Promise<void>
|
|
29
34
|
/** Open the browser automatically (default true). */
|
|
30
35
|
openBrowser?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Read the saved identity token. Providing it registers `<cli> auth status`,
|
|
38
|
+
* and lets `logout` warn when a token is still reachable afterwards — an
|
|
39
|
+
* environment variable that outranks the config file, typically.
|
|
40
|
+
*/
|
|
41
|
+
getToken?: () => string | undefined
|
|
42
|
+
/** Where the token came from, for display: a config path, or an env var name. */
|
|
43
|
+
tokenOrigin?: () => string | undefined
|
|
44
|
+
/** Extra rows for `auth status`, e.g. the API base URL the app will call. */
|
|
45
|
+
statusRows?: () => [string, string][]
|
|
46
|
+
/** One-line hint printed after a successful login. */
|
|
47
|
+
nextSteps?: string
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
function resolveBase(b: AuthCliDeps['authBase']): string {
|
|
@@ -42,14 +59,60 @@ function openInBrowser(url: string): void {
|
|
|
42
59
|
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch { /* print fallback below */ }
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
interface IdentityClaims {
|
|
63
|
+
sub?: string
|
|
64
|
+
exp?: number
|
|
65
|
+
iss?: string
|
|
66
|
+
type?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the token's claims WITHOUT verifying the signature — display only.
|
|
71
|
+
* Verification is the server's job; a CLI holds no key and needs none.
|
|
72
|
+
*/
|
|
73
|
+
function claimsOf(token: string): IdentityClaims {
|
|
74
|
+
return (parseJwtPayload(token) ?? {}) as IdentityClaims
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expiryLine(claims: IdentityClaims): string {
|
|
78
|
+
if (!claims.exp) return 'unknown'
|
|
79
|
+
const at = new Date(claims.exp * 1000).toISOString().replace('T', ' ').slice(0, 16)
|
|
80
|
+
return `${at} UTC (${fmtRelative(claims.exp)})`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `<cli> auth` and `<cli> auth status` — what the saved token says, no network. */
|
|
84
|
+
export function buildAuthCommand(deps: AuthCliDeps): Command {
|
|
85
|
+
const cli = deps.cliName ?? 'cli'
|
|
86
|
+
|
|
87
|
+
const status = (): void => {
|
|
88
|
+
const token = deps.getToken?.()
|
|
89
|
+
if (!token) fatal(`not signed in — run \`${cli} login\``)
|
|
90
|
+
|
|
91
|
+
const claims = claimsOf(token)
|
|
92
|
+
const rows: [string, string][] = [...(deps.statusRows?.() ?? [])]
|
|
93
|
+
rows.push(['user', claims.sub ?? 'unknown'])
|
|
94
|
+
rows.push(['token type', claims.type ?? 'unknown'])
|
|
95
|
+
// `iss` is absent from tokens minted before the realm started claiming one;
|
|
96
|
+
// an empty row would be noise, so show it only when it is there.
|
|
97
|
+
if (claims.iss) rows.push(['issuer', claims.iss])
|
|
98
|
+
rows.push(['expires', expiryLine(claims)])
|
|
99
|
+
const origin = deps.tokenOrigin?.()
|
|
100
|
+
if (origin) rows.push(['token from', origin])
|
|
101
|
+
kv(rows)
|
|
102
|
+
|
|
103
|
+
if (claims.exp && claims.exp * 1000 <= Date.now()) {
|
|
104
|
+
console.log('')
|
|
105
|
+
fatal(`the token has expired — run \`${cli} login\``)
|
|
106
|
+
}
|
|
107
|
+
console.log('')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cmd = new Command('auth')
|
|
111
|
+
.description('Show the current sign-in (alias: auth status)')
|
|
112
|
+
.action(status)
|
|
113
|
+
|
|
114
|
+
cmd.addCommand(new Command('status').description('Show the current sign-in').action(status))
|
|
115
|
+
return cmd
|
|
53
116
|
}
|
|
54
117
|
|
|
55
118
|
export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
|
|
@@ -67,13 +130,23 @@ export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
|
|
|
67
130
|
console.log(` ${clr.dim}If it does not open, visit:${clr.reset}\n ${clr.cyan}${start.url}${clr.reset}\n`)
|
|
68
131
|
if (deps.openBrowser !== false && opts.open !== false) openInBrowser(start.url)
|
|
69
132
|
|
|
70
|
-
|
|
133
|
+
// Without this the command sits silent for minutes and looks hung.
|
|
134
|
+
const minutes = Math.max(1, Math.round(start.expiresIn / 60))
|
|
135
|
+
console.log(` ${clr.dim}Waiting for approval — the link is valid for ${minutes}m…${clr.reset}`)
|
|
136
|
+
|
|
137
|
+
// Stop exactly when the server drops the state, not before: a shorter
|
|
138
|
+
// client timeout would report failure while the link still works.
|
|
139
|
+
const { token } = await pollCliAuth(base, start.state, { timeoutMs: start.expiresIn * 1000 })
|
|
140
|
+
.catch((e: Error) => fatal(e.message))
|
|
71
141
|
await deps.onToken(token)
|
|
72
142
|
|
|
73
|
-
const
|
|
143
|
+
const claims = claimsOf(token)
|
|
74
144
|
success('Logged in.')
|
|
75
|
-
|
|
76
|
-
|
|
145
|
+
const rows: [string, string][] = []
|
|
146
|
+
if (claims.sub) rows.push(['user', claims.sub])
|
|
147
|
+
if (claims.exp) rows.push(['expires', expiryLine(claims)])
|
|
148
|
+
if (rows.length) kv(rows)
|
|
149
|
+
console.log(` ${clr.dim}Next: ${deps.nextSteps ?? `${cli} use · ${cli} orgs`}${clr.reset}\n`)
|
|
77
150
|
}),
|
|
78
151
|
)
|
|
79
152
|
|
|
@@ -84,6 +157,20 @@ export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
|
|
|
84
157
|
if (deps.onLogout) await deps.onLogout()
|
|
85
158
|
else warn('No logout handler configured.')
|
|
86
159
|
success('Logged out.')
|
|
160
|
+
|
|
161
|
+
// A token in the environment outranks the config file, so clearing the
|
|
162
|
+
// file alone can leave the user still signed in without knowing it.
|
|
163
|
+
const leftover = deps.getToken?.()
|
|
164
|
+
if (leftover) {
|
|
165
|
+
const origin = deps.tokenOrigin?.()
|
|
166
|
+
warn(`A token is still in effect${origin ? ` (${origin})` : ''} and takes precedence.`)
|
|
167
|
+
}
|
|
168
|
+
console.log(
|
|
169
|
+
` ${clr.dim}The token stays valid on the server until it expires; there is no server-side revocation.${clr.reset}\n`,
|
|
170
|
+
)
|
|
87
171
|
}),
|
|
88
172
|
)
|
|
173
|
+
|
|
174
|
+
// `auth status` only makes sense when the app can hand back what it stored.
|
|
175
|
+
if (deps.getToken) program.addCommand(buildAuthCommand(deps))
|
|
89
176
|
}
|
package/src/hono.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Context, Next } from 'hono'
|
|
2
|
-
import {
|
|
2
|
+
import { verifier, type IdentityClaims, type Verifier } from './identity.js'
|
|
3
3
|
|
|
4
4
|
export interface ServiceAuthUser {
|
|
5
5
|
userId: string
|
|
6
6
|
orgId?: string
|
|
7
7
|
role?: string
|
|
8
|
-
|
|
8
|
+
email?: string
|
|
9
|
+
tokenType: 'user' | 'service' | 'assistant'
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
declare module 'hono' {
|
|
@@ -15,26 +16,79 @@ declare module 'hono' {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Where the realm's public keys are, and what to expect in a token.
|
|
20
|
+
*
|
|
21
|
+
* Read from the environment so a service picks this up with no code of its own:
|
|
22
|
+
*
|
|
23
|
+
* AUTH_JWKS_URI e.g. https://auth.example.com/.well-known/jwks.json
|
|
24
|
+
* AUTH_ISSUER expected `iss`; unset means "do not check"
|
|
25
|
+
* AUTH_AUDIENCE expected `aud`; unset means "do not check"
|
|
26
|
+
* JWT_SECRET legacy HS256 secret, accepted alongside ES256 while set
|
|
27
|
+
*
|
|
28
|
+
* With only JWT_SECRET set, behaviour is exactly what it was before JWKS
|
|
29
|
+
* existed, so a service can be upgraded before the realm has keys.
|
|
30
|
+
*/
|
|
31
|
+
function fromEnv(env: NodeJS.ProcessEnv): Verifier | { error: string } {
|
|
32
|
+
const jwksUri = env['AUTH_JWKS_URI']
|
|
33
|
+
const legacy = env['JWT_SECRET']
|
|
34
|
+
if (!jwksUri && !legacy) {
|
|
35
|
+
return { error: 'neither AUTH_JWKS_URI nor JWT_SECRET is configured' }
|
|
36
|
+
}
|
|
37
|
+
return verifier({
|
|
38
|
+
...(jwksUri ? { jwksUri } : {}),
|
|
39
|
+
...(env['AUTH_ISSUER'] ? { issuer: env['AUTH_ISSUER'] } : {}),
|
|
40
|
+
...(env['AUTH_AUDIENCE'] ? { audience: env['AUTH_AUDIENCE'] } : {}),
|
|
41
|
+
...(legacy ? { legacyHs256Secret: legacy } : {}),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Built once: the verifier owns a JWKS cache, and rebuilding it per request
|
|
46
|
+
// would refetch the key set on every call.
|
|
47
|
+
let cachedVerifier: Verifier | { error: string } | undefined
|
|
48
|
+
|
|
49
|
+
/** Tests change the environment between cases. */
|
|
50
|
+
export function resetAuthVerifier(): void {
|
|
51
|
+
cachedVerifier = undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function claimsToUser(claims: IdentityClaims): ServiceAuthUser {
|
|
55
|
+
const type = claims.type
|
|
56
|
+
return {
|
|
57
|
+
userId: String(claims.sub ?? ''),
|
|
58
|
+
orgId: claims['org_id'] as string | undefined,
|
|
59
|
+
role: claims['role'] as string | undefined,
|
|
60
|
+
email: claims['email'] as string | undefined,
|
|
61
|
+
// Anything unrecognised is treated as a user token: `service` and
|
|
62
|
+
// `assistant` open doors a plain user token must not, so they are named
|
|
63
|
+
// explicitly and everything else falls to the least privileged reading.
|
|
64
|
+
tokenType: type === 'service' ? 'service' : type === 'assistant' ? 'assistant' : 'user',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Hono middleware — verifies the realm's identity token from
|
|
70
|
+
* `Authorization: Bearer` and sets `c.var.auth`.
|
|
71
|
+
*
|
|
72
|
+
* ES256 verified against the realm's published JWKS, plus HS256 while a legacy
|
|
73
|
+
* secret is configured. The shared secret is a migration door: any service
|
|
74
|
+
* holding it can also MINT tokens, including one claiming to be another user,
|
|
75
|
+
* so it should be removed once the realm issues ES256 everywhere.
|
|
21
76
|
*/
|
|
22
77
|
export async function requireAuth(c: Context, next: Next) {
|
|
23
78
|
const header = c.req.header('Authorization')
|
|
24
79
|
if (!header?.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401)
|
|
25
80
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
81
|
+
cachedVerifier ??= fromEnv(process.env)
|
|
82
|
+
if (typeof cachedVerifier !== 'function') {
|
|
83
|
+
// A misconfigured service must not look like a rejected caller: 500 tells
|
|
84
|
+
// the operator it is their problem, 401 would send the user in circles.
|
|
85
|
+
console.error(`[auth] ${cachedVerifier.error}`)
|
|
86
|
+
return c.json({ error: 'auth is not configured' }, 500)
|
|
87
|
+
}
|
|
29
88
|
|
|
30
|
-
const claims =
|
|
89
|
+
const claims = await cachedVerifier(header.slice(7))
|
|
31
90
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401)
|
|
32
91
|
|
|
33
|
-
c.set('auth',
|
|
34
|
-
userId: String(claims['sub'] ?? ''),
|
|
35
|
-
orgId: claims['org_id'] as string | undefined,
|
|
36
|
-
role: claims['role'] as string | undefined,
|
|
37
|
-
tokenType: claims['type'] === 'service' ? 'service' : 'user',
|
|
38
|
-
})
|
|
92
|
+
c.set('auth', claimsToUser(claims))
|
|
39
93
|
return next()
|
|
40
94
|
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realm identity tokens: minting, publication and verification.
|
|
3
|
+
*
|
|
4
|
+
* One realm has one issuer — its auth-service — and one signing key pair. Every
|
|
5
|
+
* other service in the realm (org, iam, billing, pay, and each product API)
|
|
6
|
+
* verifies with the PUBLIC key it fetches from `/.well-known/jwks.json`, and
|
|
7
|
+
* therefore cannot mint anything.
|
|
8
|
+
*
|
|
9
|
+
* That asymmetry is the whole point. With a shared HMAC secret, every service
|
|
10
|
+
* holding it can also *issue* tokens — including one that claims to be another
|
|
11
|
+
* user. This is not theoretical: a product API was found minting
|
|
12
|
+
* `{ sub: <some other user>, type: 'user' }` to write a membership on that
|
|
13
|
+
* user's behalf. Under ES256 that is impossible by construction: the private
|
|
14
|
+
* key never leaves auth-service.
|
|
15
|
+
*
|
|
16
|
+
* Claims: `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `type`. Org and role are
|
|
17
|
+
* deliberately absent — membership changes while a token lives, so a token that
|
|
18
|
+
* carries a role goes stale and lies. Consumers ask the org service instead.
|
|
19
|
+
*
|
|
20
|
+
* HS256 stays available during migration; see `verifier()` below.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
SignJWT,
|
|
25
|
+
jwtVerify,
|
|
26
|
+
exportJWK,
|
|
27
|
+
importPKCS8,
|
|
28
|
+
importSPKI,
|
|
29
|
+
calculateJwkThumbprint,
|
|
30
|
+
createLocalJWKSet,
|
|
31
|
+
createRemoteJWKSet,
|
|
32
|
+
type JSONWebKeySet,
|
|
33
|
+
type JWK,
|
|
34
|
+
} from 'jose'
|
|
35
|
+
|
|
36
|
+
export const IDENTITY_ALG = 'ES256'
|
|
37
|
+
|
|
38
|
+
/** `human` signs in; `service` is a service acting as itself; `assistant` is a narrowed token. */
|
|
39
|
+
export type IdentityType = 'human' | 'service' | 'assistant'
|
|
40
|
+
|
|
41
|
+
export interface IdentityClaims {
|
|
42
|
+
sub: string
|
|
43
|
+
type: IdentityType
|
|
44
|
+
iss?: string
|
|
45
|
+
aud?: string
|
|
46
|
+
exp?: number
|
|
47
|
+
iat?: number
|
|
48
|
+
jti?: string
|
|
49
|
+
/** Narrowed tokens carry the org they are scoped to; identity tokens do not. */
|
|
50
|
+
org_id?: string
|
|
51
|
+
scope?: string
|
|
52
|
+
[key: string]: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SigningKey {
|
|
56
|
+
privateKey: CryptoKey
|
|
57
|
+
/** Published in the JWKS and in every token header, so verifiers pick the right key. */
|
|
58
|
+
kid: string
|
|
59
|
+
publicJwk: JWK
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Key material ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Accept a PEM as-is, or base64 of a PEM.
|
|
66
|
+
*
|
|
67
|
+
* Environment variables and PEM disagree about newlines: a key pasted into a
|
|
68
|
+
* shell, a Kubernetes Secret or a CI variable loses them often enough that
|
|
69
|
+
* requiring raw PEM guarantees confusing failures. Base64 survives every one of
|
|
70
|
+
* those paths, so both forms are accepted.
|
|
71
|
+
*/
|
|
72
|
+
function toPem(raw: string, label: 'PRIVATE' | 'PUBLIC'): string {
|
|
73
|
+
const value = raw.trim()
|
|
74
|
+
if (value.includes('-----BEGIN')) return value.replace(/\\n/g, '\n')
|
|
75
|
+
const decoded = Buffer.from(value, 'base64').toString('utf8')
|
|
76
|
+
if (decoded.includes('-----BEGIN')) return decoded.trim()
|
|
77
|
+
throw new Error(`not a ${label} key: expected PEM, or base64 of PEM`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function jwkOf(key: CryptoKey, kid?: string): Promise<JWK> {
|
|
81
|
+
const jwk = await exportJWK(key)
|
|
82
|
+
jwk.alg = IDENTITY_ALG
|
|
83
|
+
jwk.use = 'sig'
|
|
84
|
+
// A thumbprint (RFC 7638) is derived from the key itself, so the same key
|
|
85
|
+
// always gets the same id and two different keys can never collide. Deriving
|
|
86
|
+
// beats configuring: one less value to keep in step across services.
|
|
87
|
+
jwk.kid = kid ?? (await calculateJwkThumbprint(jwk))
|
|
88
|
+
return jwk
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Load the signing key pair from a PKCS#8 private key. */
|
|
92
|
+
export async function loadSigningKey(privateKeyPem: string, kid?: string): Promise<SigningKey> {
|
|
93
|
+
const pem = toPem(privateKeyPem, 'PRIVATE')
|
|
94
|
+
const privateKey = await importPKCS8(pem, IDENTITY_ALG, { extractable: true })
|
|
95
|
+
const publicJwk = await jwkOf(privateKey, kid)
|
|
96
|
+
// The private half must never reach the JWKS; exportJWK on a private key
|
|
97
|
+
// returns `d`, and publishing it would hand the realm away.
|
|
98
|
+
delete publicJwk.d
|
|
99
|
+
return { privateKey, kid: publicJwk.kid as string, publicJwk }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load a public key (SPKI PEM) for publication only — a retired key during rotation. */
|
|
103
|
+
export async function loadPublicKey(publicKeyPem: string, kid?: string): Promise<JWK> {
|
|
104
|
+
const key = await importSPKI(toPem(publicKeyPem, 'PUBLIC'), IDENTITY_ALG, { extractable: true })
|
|
105
|
+
return jwkOf(key, kid)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The document served at `/.well-known/jwks.json`.
|
|
110
|
+
*
|
|
111
|
+
* Rotation works by publishing both keys for longer than a token lives: add the
|
|
112
|
+
* new key, sign with it, keep the old one listed until every token signed by it
|
|
113
|
+
* has expired, then drop it.
|
|
114
|
+
*/
|
|
115
|
+
export function jwks(signing: SigningKey, retired: JWK[] = []): JSONWebKeySet {
|
|
116
|
+
return { keys: [signing.publicJwk, ...retired] }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Minting ─────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface MintOptions {
|
|
122
|
+
issuer: string
|
|
123
|
+
audience: string
|
|
124
|
+
expiresInSeconds: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function mint(
|
|
128
|
+
key: SigningKey,
|
|
129
|
+
claims: IdentityClaims,
|
|
130
|
+
opts: MintOptions,
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
const { sub, type, ...rest } = claims
|
|
133
|
+
return new SignJWT({ type, ...rest })
|
|
134
|
+
.setProtectedHeader({ alg: IDENTITY_ALG, kid: key.kid })
|
|
135
|
+
.setSubject(sub)
|
|
136
|
+
.setIssuer(opts.issuer)
|
|
137
|
+
.setAudience(opts.audience)
|
|
138
|
+
.setIssuedAt()
|
|
139
|
+
// A unique id per token is what makes a future revocation list possible;
|
|
140
|
+
// without it a leaked token cannot be named, only waited out.
|
|
141
|
+
.setJti(crypto.randomUUID())
|
|
142
|
+
.setExpirationTime(`${opts.expiresInSeconds}s`)
|
|
143
|
+
.sign(key.privateKey)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Verification ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export interface VerifierOptions {
|
|
149
|
+
/** Where to fetch the realm's public keys. jose caches and refetches on an unknown `kid`. */
|
|
150
|
+
jwksUri?: string
|
|
151
|
+
/** A fixed key set instead of a URL — for tests, or a service that ships with the realm. */
|
|
152
|
+
jwks?: JSONWebKeySet
|
|
153
|
+
issuer?: string
|
|
154
|
+
audience?: string
|
|
155
|
+
/**
|
|
156
|
+
* Accept HS256 tokens signed with this shared secret, in addition to ES256.
|
|
157
|
+
*
|
|
158
|
+
* Migration only. While it is set, any holder of the secret can still mint
|
|
159
|
+
* tokens for this realm — including tokens impersonating a user — so it must
|
|
160
|
+
* be removed once every issuer has moved to ES256.
|
|
161
|
+
*/
|
|
162
|
+
legacyHs256Secret?: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface Verifier {
|
|
166
|
+
(token: string): Promise<IdentityClaims | null>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build a verifier for the realm.
|
|
171
|
+
*
|
|
172
|
+
* Returns `null` rather than throwing on every rejection: a caller answering an
|
|
173
|
+
* HTTP request wants one 401 path, not a taxonomy of crypto errors. Reasons
|
|
174
|
+
* worth acting on (an unreachable JWKS, say) surface through `onError`.
|
|
175
|
+
*/
|
|
176
|
+
export function verifier(opts: VerifierOptions & { onError?: (err: unknown) => void }): Verifier {
|
|
177
|
+
if (!opts.jwksUri && !opts.jwks && !opts.legacyHs256Secret) {
|
|
178
|
+
throw new Error('verifier needs jwksUri, jwks, or legacyHs256Secret')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const keys = opts.jwks
|
|
182
|
+
? createLocalJWKSet(opts.jwks)
|
|
183
|
+
: opts.jwksUri
|
|
184
|
+
? createRemoteJWKSet(new URL(opts.jwksUri))
|
|
185
|
+
: undefined
|
|
186
|
+
|
|
187
|
+
const legacy = opts.legacyHs256Secret
|
|
188
|
+
? new TextEncoder().encode(opts.legacyHs256Secret)
|
|
189
|
+
: undefined
|
|
190
|
+
|
|
191
|
+
const expectations = {
|
|
192
|
+
...(opts.issuer ? { issuer: opts.issuer } : {}),
|
|
193
|
+
...(opts.audience ? { audience: opts.audience } : {}),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return async (token: string): Promise<IdentityClaims | null> => {
|
|
197
|
+
if (keys) {
|
|
198
|
+
try {
|
|
199
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
200
|
+
...expectations,
|
|
201
|
+
algorithms: [IDENTITY_ALG],
|
|
202
|
+
})
|
|
203
|
+
return payload as unknown as IdentityClaims
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// Fall through to the legacy secret when one is configured: during
|
|
206
|
+
// migration both kinds of token are in circulation at once.
|
|
207
|
+
if (!legacy) {
|
|
208
|
+
opts.onError?.(err)
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!legacy) return null
|
|
215
|
+
try {
|
|
216
|
+
// Legacy tokens predate iss/aud, so those are not required of them.
|
|
217
|
+
const { payload } = await jwtVerify(token, legacy, { algorithms: ['HS256'] })
|
|
218
|
+
return payload as unknown as IdentityClaims
|
|
219
|
+
} catch (err) {
|
|
220
|
+
opts.onError?.(err)
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,20 +21,6 @@ export { hashToken, looksLikeJwt, stripBearer } from './token';
|
|
|
21
21
|
export { signHs256Jwt, verifyHs256Jwt } from './jwt';
|
|
22
22
|
export type { JwtClaims } from './jwt';
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
getAuthPublicUrl,
|
|
28
|
-
getAccountPublicUrl,
|
|
29
|
-
getAppBasePath,
|
|
30
|
-
getPublicSiteUrl,
|
|
31
|
-
buildPublicUrl,
|
|
32
|
-
buildAuthLoginUrl,
|
|
33
|
-
buildAuthLogoutUrl,
|
|
34
|
-
buildAccountProfileUrl,
|
|
35
|
-
} from './url-helpers';
|
|
36
|
-
|
|
37
|
-
// Next.js session + edge exports are intentionally NOT re-exported here.
|
|
38
|
-
// Use subpath imports: @baseworks/auth/session, @baseworks/auth/edge
|
|
39
|
-
// This keeps the main index Worker-safe (no next/server dependency).
|
|
40
|
-
|
|
24
|
+
// Browser sessions are not here. Signing a person in, and keeping them signed
|
|
25
|
+
// in, is auth-service's job: this package is what a consumer shares, not a
|
|
26
|
+
// second place to run a login.
|
package/src/jwt.ts
CHANGED
package/dist/chunk-3NJASEF4.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// src/url-helpers.ts
|
|
2
|
-
function normalizeUrlLike(value) {
|
|
3
|
-
if (value === "/") return "";
|
|
4
|
-
return value.replace(/\/+$/, "");
|
|
5
|
-
}
|
|
6
|
-
function joinPublicUrl(base, path = "") {
|
|
7
|
-
const normalizedBase = normalizeUrlLike(base);
|
|
8
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
9
|
-
if (!normalizedBase) return normalizedPath;
|
|
10
|
-
if (/^https?:\/\//.test(normalizedBase)) {
|
|
11
|
-
return new URL(normalizedPath, `${normalizedBase}/`).toString();
|
|
12
|
-
}
|
|
13
|
-
return `${normalizedBase}${normalizedPath}`;
|
|
14
|
-
}
|
|
15
|
-
function getAuthPublicUrl() {
|
|
16
|
-
return normalizeUrlLike(process.env.NEXT_PUBLIC_AUTH_URL ?? "/auth");
|
|
17
|
-
}
|
|
18
|
-
function getAccountPublicUrl() {
|
|
19
|
-
return normalizeUrlLike(process.env.NEXT_PUBLIC_ACCOUNT_URL ?? "/account");
|
|
20
|
-
}
|
|
21
|
-
function getAppBasePath(defaultPath = "") {
|
|
22
|
-
return normalizeUrlLike(
|
|
23
|
-
process.env.NEXT_PUBLIC_APP_BASE_PATH ?? process.env.APP_BASE_PATH ?? defaultPath
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
function getPublicSiteUrl() {
|
|
27
|
-
return normalizeUrlLike(process.env.APP_PUBLIC_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "");
|
|
28
|
-
}
|
|
29
|
-
function buildPublicUrl(pathOrUrl, fallbackOrigin) {
|
|
30
|
-
if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
|
|
31
|
-
const origin = getPublicSiteUrl() || fallbackOrigin;
|
|
32
|
-
if (!origin) return pathOrUrl;
|
|
33
|
-
return new URL(pathOrUrl, `${normalizeUrlLike(origin)}/`).toString();
|
|
34
|
-
}
|
|
35
|
-
function buildAuthUrl(path, redirectTo, origin) {
|
|
36
|
-
const target = joinPublicUrl(getAuthPublicUrl(), path);
|
|
37
|
-
const targetIsAbsolute = /^https?:\/\//.test(target);
|
|
38
|
-
const fallbackOrigin = "http://localhost";
|
|
39
|
-
const url = new URL(target, origin ?? fallbackOrigin);
|
|
40
|
-
url.searchParams.set("redirectTo", redirectTo);
|
|
41
|
-
const href = url.toString();
|
|
42
|
-
if (origin || targetIsAbsolute) return href;
|
|
43
|
-
return href.replace(fallbackOrigin, "");
|
|
44
|
-
}
|
|
45
|
-
function buildAuthLoginUrl(redirectTo, origin) {
|
|
46
|
-
return buildAuthUrl("/login", redirectTo, origin);
|
|
47
|
-
}
|
|
48
|
-
function buildAuthLogoutUrl(redirectTo, origin) {
|
|
49
|
-
return buildAuthUrl(
|
|
50
|
-
"/logout",
|
|
51
|
-
process.env.NEXT_PUBLIC_SIGN_OUT_REDIRECT_URL ?? redirectTo,
|
|
52
|
-
origin
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
function buildAccountProfileUrl() {
|
|
56
|
-
return joinPublicUrl(getAccountPublicUrl(), "/profile");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export {
|
|
60
|
-
normalizeUrlLike,
|
|
61
|
-
getAuthPublicUrl,
|
|
62
|
-
getAccountPublicUrl,
|
|
63
|
-
getAppBasePath,
|
|
64
|
-
getPublicSiteUrl,
|
|
65
|
-
buildPublicUrl,
|
|
66
|
-
buildAuthLoginUrl,
|
|
67
|
-
buildAuthLogoutUrl,
|
|
68
|
-
buildAccountProfileUrl
|
|
69
|
-
};
|