@fabrika/auth 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type { DomainEvent, IssueKeyServiceSpec, KeyGrant, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
2
+
3
+ /**
4
+ * The resolved caller's identity — who Access says you are, as the IAM Worker recorded
5
+ * the principal. Exposed on `AuthContext` so apps can stamp domain rows (created_by,
6
+ * activity actor, assignee) and render "signed in as …" without a second lookup. It is
7
+ * NOT a permission surface — authorization is still `can()` / `scopedTo()`.
8
+ * - `id` — the IAM principal id (UUIDv7); stable, safe to store on domain rows.
9
+ * - `type` — 'user' (a human identity) or 'service' (a machine principal / API key).
10
+ * - `label` — human-readable: the user's email, or the service principal's name.
11
+ */
12
+ export interface PrincipalIdentity {
13
+ readonly id: string
14
+ readonly type: PrincipalType
15
+ readonly label: string
16
+ }
17
+
18
+ // ── Result surfaces ──────────────────────────────────────────────────────────
19
+ //
20
+ // Every method returns a discriminated union whose members all carry an `ok` field,
21
+ // so app code branches once on `result.ok`. The ok:true members are the rich
22
+ // `AuthContext` / `IssuedKey` / `IssuedJwt` surfaces; the ok:false members are
23
+ // the typed failures below, each carrying the HTTP status the app should return.
24
+ //
25
+ // The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
26
+ // the real (`permits`-backed) and fake (allow-all-except-deny) implementations satisfy a
27
+ // single shared shape without any casts — see the design note in the package README of the
28
+ // spec. The documented surface (`ok` / `can` / `scopedTo` / `audit`) is the interface.
29
+
30
+ /**
31
+ * The authenticated, ok:true result. `can`/`scopedTo` are local & pure (no binding call);
32
+ * only `audit` reaches the IAM Worker.
33
+ */
34
+ export interface AuthContext {
35
+ readonly ok: true
36
+ /**
37
+ * The resolved caller identity (id / type / label), or `null` for an ANONYMOUS credential — a
38
+ * passthrough JWT or a standalone (no-principal) share-link/API key. For stamping domain rows and
39
+ * display — NOT a permission surface (authorization stays `can` / `scopedTo`, which work the same
40
+ * either way). Stamp `created_by` with `principal?.id ?? null`.
41
+ */
42
+ readonly principal: PrincipalIdentity | null
43
+ /**
44
+ * Point check: may this principal do `action` (optionally within `scope`, an opaque
45
+ * `{ type, value }` coordinate)? Scope-less → satisfied by GLOBAL permissions only; a
46
+ * scoped grant never widens into a scope-less allow.
47
+ */
48
+ can(action: string, scope?: Scope): boolean
49
+ /**
50
+ * Scoping: which scope values may this principal perform `action` on within `dimension`
51
+ * (a scope type, e.g. `'organization'`)? Scopes are flat & independent — a list is always
52
+ * relative to one dimension.
53
+ * - `null` → unrestricted (holds the action globally — e.g. an admin)
54
+ * - `[]` → no access within this dimension at all
55
+ * - non-empty → exactly those (opaque, app-owned) scope values
56
+ * Consume via `applyScope` so the empty-`IN ()` trap is handled once. `dimension` is
57
+ * REQUIRED — multiple scope types may coexist, so the caller must say which one.
58
+ */
59
+ scopedTo(action: string, dimension: string): string[] | null
60
+ /** Emit a domain audit event; app/principal/requestId are auto-attached. Fire-and-forget. */
61
+ audit(event: DomainEvent): Promise<void>
62
+ }
63
+
64
+ /** Successful `issueKey` — the plaintext `px_` token returned ONCE; `id` is the durable revoke handle. */
65
+ export interface IssuedKey {
66
+ readonly ok: true
67
+ /** Plaintext `px_` token — show once, never persist. Carry as a bearer or a share-link path token. */
68
+ token: string
69
+ /** The credential id (safe to store/reference; pass to `revokeKey`). */
70
+ id: string
71
+ /**
72
+ * The principal the key is bound to — the freshly-created service principal (`service` mode) or the
73
+ * issuer's own id (self-bind); absent for a standalone (anonymous) share link.
74
+ */
75
+ principalId?: string
76
+ }
77
+
78
+ /** Successful `issueJwt` — the signed passthrough token returned ONCE (audit-only, not revocable). */
79
+ export interface IssuedJwt {
80
+ readonly ok: true
81
+ /** The signed JWT — carry it as `Authorization: Bearer`. Verified locally by the app's SDK. */
82
+ token: string
83
+ /** Expiry (unix seconds). */
84
+ expiresAt: number
85
+ /** The token's audit reference (there is no DB row to revoke). */
86
+ id: string
87
+ }
88
+
89
+ /** Successful `revokeKey`. `revoked` is false when the credential was already revoked. */
90
+ export interface RevokedKey {
91
+ readonly ok: true
92
+ /** True when this call flipped the credential to revoked; false if it was already revoked (idempotent). */
93
+ revoked: boolean
94
+ }
95
+
96
+ /** Successful `listPrincipals` — the app's people directory (user principals). */
97
+ export interface PrincipalList {
98
+ readonly ok: true
99
+ principals: PrincipalListItem[]
100
+ }
101
+
102
+ // ── Failures ─────────────────────────────────────────────────────────────────
103
+
104
+ /** `issueKey` / `issueJwt` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
105
+ export interface IssueFailure {
106
+ readonly ok: false
107
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
108
+ status: 401 | 403
109
+ }
110
+
111
+ /** `revokeKey` failure. missing/invalid → 401; unknown/disabled/not_allowed → 403; not_found → 404. */
112
+ export interface RevokeFailure {
113
+ readonly ok: false
114
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found'
115
+ status: 401 | 403 | 404
116
+ }
117
+
118
+ /** `listPrincipals` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
119
+ export interface ListPrincipalsFailure {
120
+ readonly ok: false
121
+ reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
122
+ status: 401 | 403
123
+ }
124
+
125
+ // ── Inputs ───────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * App-supplied portion of `issueKey` — an opaque, stored, revocable `px_` credential (API key /
129
+ * share link). The SDK fills `app` and the issuer's forwarded credential (the caller's `px_token` /
130
+ * `px_` key + requestId) from the request, so app code can never self-assert the issuer.
131
+ */
132
+ export interface IssueKeyRequest {
133
+ /**
134
+ * Create a NEW machine (service) principal and bind the key to it — the folded service-token
135
+ * path. The issuer must hold every `permissions` action on `scope`. When set, `principalId` /
136
+ * `permissions` (the bind / standalone modes) are ignored.
137
+ */
138
+ service?: IssueKeyServiceSpec
139
+ /**
140
+ * Bind to a principal (the credential then carries that principal's LIVE perms; inline
141
+ * `permissions` downscope it). v1: only the issuer's OWN id. Omit for a standalone share link.
142
+ */
143
+ principalId?: string
144
+ /** Inline grants — the frozen set (standalone share link) and/or a downscope restriction (bound). */
145
+ permissions?: KeyGrant[]
146
+ label?: string
147
+ /** Absolute credential expiry (unix seconds); omitted = no expiry. */
148
+ expiresAt?: number
149
+ }
150
+
151
+ /**
152
+ * App-supplied portion of `issueJwt` — a stateless passthrough access token (audit-only, TTL-bounded,
153
+ * NOT revocable). The SDK fills `app` and the issuer's forwarded credential from the request.
154
+ */
155
+ export interface IssueJwtRequest {
156
+ /** Inline grants the passthrough token carries (frozen at issue). */
157
+ permissions: KeyGrant[]
158
+ label?: string
159
+ /** Requested lifetime (seconds); capped by the server. Defaults to the standard token TTL. */
160
+ ttl?: number
161
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Local access-token verification — the only thing this SDK still does with a credential.
3
+ *
4
+ * Since [ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md) the PROXY is the only
5
+ * enforcement point: it matches the app's gates, resolves the credential, and injects the verified
6
+ * token as `PROXY_TOKEN_HEADER`. The app must NOT trust that header blindly, so every token is
7
+ * re-verified here against IAM's published JWKS — signature, `iss`, `aud`, `exp` — before it can
8
+ * become an `AuthContext`.
9
+ *
10
+ * `verify` is THREE-STATE, exactly like `@fabrika/proxy`'s verifier: "this token is bad" (the
11
+ * caller's fault, 401) and "we could not check" (ours, 503) are different denials. Both deny; only
12
+ * the second is an incident. Collapsing them would let a JWKS outage look like a forged token.
13
+ *
14
+ * The JWKS is cached PER BINDING in a module-level WeakMap rather than per instance: `@fabrika/app`
15
+ * calls its `middleware(env)` factory on every request, so an SDK object built there is per-request
16
+ * while the binding lives for the isolate/process. An unknown `kid` (a key rotated in) forces exactly
17
+ * one refetch, and a stale verifier is never reused after a fetch failure — the previous key set may
18
+ * have been rotated out precisely because it was compromised.
19
+ */
20
+
21
+ import { type AccessTokenClaims, type IamRpc, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
22
+ import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
23
+
24
+ export type VerifyResult =
25
+ | { ok: true; claims: AccessTokenClaims }
26
+ /** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
27
+ | { ok: false; reason: 'invalid' }
28
+ /** The key set could not be obtained, so nothing could be checked. Deny, but say why. */
29
+ | { ok: false; reason: 'unavailable' }
30
+
31
+ /** How long a fetched JWKS is reused before a refetch (a rotation also forces one on demand). */
32
+ const JWKS_TTL_SECONDS = 600
33
+
34
+ /** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
35
+ const NO_KEY = Symbol('no-matching-key')
36
+
37
+ type JwksVerifier = ReturnType<typeof createLocalJWKSet>
38
+
39
+ /** Raised only by `jwks()`, so `verify` can tell an unfetchable key set from an untrustworthy token. */
40
+ class JwksUnavailableError extends Error {
41
+ constructor() {
42
+ super('the IAM key set could not be fetched')
43
+ this.name = 'JwksUnavailableError'
44
+ }
45
+ }
46
+
47
+ /** JWKS verifier cached per binding, so it survives the per-request SDK objects built over it. */
48
+ const jwksCache = new WeakMap<IamRpc, { verifier: JwksVerifier; expiresAt: number }>()
49
+
50
+ /** Verifies tokens for ONE (binding, issuer, audience) triple. Cheap to construct; the cache is shared. */
51
+ export class TokenVerifier {
52
+ constructor(
53
+ private readonly binding: IamRpc,
54
+ private readonly issuer: string,
55
+ private readonly audience: string,
56
+ private readonly now: () => number = () => Math.floor(Date.now() / 1000),
57
+ ) {}
58
+
59
+ async verify(token: string): Promise<VerifyResult> {
60
+ let payload: unknown
61
+ try {
62
+ payload = await this.verifyWith(token, false)
63
+ if (payload === NO_KEY) {
64
+ // Unknown kid — a key was rotated in. Refetch the JWKS once and retry.
65
+ payload = await this.verifyWith(token, true)
66
+ }
67
+ } catch (err) {
68
+ if (err instanceof JwksUnavailableError) {
69
+ return { ok: false, reason: 'unavailable' }
70
+ }
71
+ throw err
72
+ }
73
+ if (payload === NO_KEY || payload === null) {
74
+ return { ok: false, reason: 'invalid' }
75
+ }
76
+ const claims = parseAccessClaims(payload)
77
+ return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
78
+ }
79
+
80
+ /** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
81
+ private async verifyWith(token: string, force: boolean): Promise<unknown> {
82
+ const jwks = await this.jwks(force)
83
+ try {
84
+ const { payload } = await jwtVerify(token, jwks, { issuer: this.issuer, audience: this.audience })
85
+ return payload
86
+ } catch (err) {
87
+ return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
88
+ }
89
+ }
90
+
91
+ private async jwks(force: boolean): Promise<JwksVerifier> {
92
+ const now = this.now()
93
+ const cached = jwksCache.get(this.binding)
94
+ if (!force && cached !== undefined && cached.expiresAt > now) {
95
+ return cached.verifier
96
+ }
97
+ let keys: Jwks
98
+ try {
99
+ keys = await this.binding.getJwks()
100
+ } catch {
101
+ // Never re-wrap the cause: it can stringify a request URL or a transport credential.
102
+ throw new JwksUnavailableError()
103
+ }
104
+ const verifier = createLocalJWKSet(keys)
105
+ jwksCache.set(this.binding, { verifier, expiresAt: now + JWKS_TTL_SECONDS })
106
+ return verifier
107
+ }
108
+ }