@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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@fabrika/auth",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/auth"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@fabrika/auth-core": "^0.0.1",
30
+ "jose": "^5.9.0"
31
+ }
32
+ }
package/src/client.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { AccessTokenClaims, DomainEvent, IamRpc, PermissionEntry, Scope } from '@fabrika/auth-core'
2
+ import { permits, scopedValues } from '@fabrika/auth-core'
3
+ import { readCredentials } from './request'
4
+ import type {
5
+ AuthContext,
6
+ IssuedJwt,
7
+ IssuedKey,
8
+ IssueFailure,
9
+ IssueJwtRequest,
10
+ IssueKeyRequest,
11
+ ListPrincipalsFailure,
12
+ PrincipalIdentity,
13
+ PrincipalList,
14
+ RevokedKey,
15
+ RevokeFailure,
16
+ } from './types'
17
+
18
+ // ── AuthContext (real) ─────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * The real `AuthContext`. `can`/`scopedTo` are pure functions over the resolved permission array (no
22
+ * per-check round-trip); only `audit` calls the binding. Backs BOTH a principal-bound caller (cookie
23
+ * session / API key) and an ANONYMOUS one (passthrough JWT / standalone share link) — the latter has
24
+ * `principal = null`; `audit` then attributes to the credential label + token id, principal null.
25
+ */
26
+ class RealAuthContext implements AuthContext {
27
+ readonly ok = true
28
+
29
+ constructor(
30
+ private readonly binding: IamRpc,
31
+ private readonly appId: string,
32
+ private readonly permissions: PermissionEntry[],
33
+ private readonly requestId: string,
34
+ readonly principal: PrincipalIdentity | null,
35
+ /** Audit actor label — the principal's label, or the credential/jwt label. */
36
+ private readonly auditLabel: string,
37
+ /** The credential/token id, for the audit linkage of an anonymous caller (null when bound). */
38
+ private readonly anonymousTokenId: string | null,
39
+ ) {}
40
+
41
+ can(action: string, scope?: Scope): boolean {
42
+ // `permits` already encodes the scope-less rule: with no scope, ONLY global
43
+ // (scope === null) entries satisfy.
44
+ return permits(this.permissions, action, scope)
45
+ }
46
+
47
+ scopedTo(action: string, dimension: string): string[] | null {
48
+ // `dimension` picks the scope type — scopes are flat & independent, so a value
49
+ // list is always relative to one dimension.
50
+ return scopedValues(this.permissions, action, dimension)
51
+ }
52
+
53
+ audit(event: DomainEvent): Promise<void> {
54
+ return this.binding.audit({
55
+ app: this.appId,
56
+ requestId: this.requestId,
57
+ principalId: this.principal?.id ?? null,
58
+ principalLabel: this.auditLabel,
59
+ ...(this.anonymousTokenId === null ? {} : { credentialId: this.anonymousTokenId }),
60
+ action: event.action,
61
+ resourceType: event.resourceType,
62
+ resourceId: event.resourceId,
63
+ diff: event.diff,
64
+ metadata: event.metadata,
65
+ })
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Build an `AuthContext` from a verified access token's claims — the seam every authentication path
71
+ * uses (the proxy-injected token, a redeemed share credential, a passthrough JWT). The SDK has verified
72
+ * the token LOCALLY; this wraps its claims in the same `permits`-backed context the RPC path returns,
73
+ * so `can()` / `scopedTo()` / `audit()` behave identically. A token with no `ptype` is anonymous
74
+ * (`principal: null`).
75
+ */
76
+ export function buildAuthContext(binding: IamRpc, appId: string, claims: AccessTokenClaims, requestId: string): AuthContext {
77
+ const principal: PrincipalIdentity | null = claims.ptype === undefined
78
+ ? null
79
+ : { id: claims.sub, type: claims.ptype, label: claims.label ?? claims.sub }
80
+ const auditLabel = claims.label ?? (principal === null ? `credential:${claims.sub}` : claims.sub)
81
+ return new RealAuthContext(binding, appId, claims.perms, requestId, principal, auditLabel, principal === null ? claims.sub : null)
82
+ }
83
+
84
+ // ── IamClient ──────────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Thin, app-facing wrapper over the IAM Worker service binding. Bakes the caller `app` id
88
+ * into the constructor so app code can never forget or mistype it. Depends ONLY on the
89
+ * `IamRpc` contract from `@fabrika/auth-core` — never on the Worker — which is what keeps the
90
+ * SDK worker-independent (the binding is the deployed Worker, reached at runtime).
91
+ */
92
+ export class IamClient {
93
+ constructor(
94
+ private readonly binding: IamRpc,
95
+ private readonly appId: string,
96
+ ) {}
97
+
98
+ /**
99
+ * List the app's people directory — the USER principals who can access this app. Forwards the
100
+ * CALLER's credential; the IAM Worker resolves the caller and scopes the roster to the
101
+ * verified app (you only ever see your own app's people). For an assignee picker / actor
102
+ * label list. Authorized for any member with a permission on the app; a zero-grant user → 403.
103
+ */
104
+ async listPrincipals(req: Request): Promise<PrincipalList | ListPrincipalsFailure> {
105
+ const { credential, requestId } = readCredentials(req)
106
+ const result = await this.binding.listPrincipals({ app: this.appId, credential, requestId })
107
+ if (result.ok) {
108
+ return { ok: true, principals: result.principals }
109
+ }
110
+ return { ok: false, reason: result.reason, status: listPrincipalsStatus(result.reason) }
111
+ }
112
+
113
+ /**
114
+ * Mint an opaque, stored, revocable `px_` credential (API key / share link). Forwards the
115
+ * REQUESTER's credentials as the issuer — the IAM Worker resolves the issuer server-side and
116
+ * enforces the delegation rule (you can only delegate what you can do). Returns the plaintext
117
+ * `px_` token ONCE; persist only the `id` (the handle for `revokeKey`). The transport — bearer
118
+ * header for a machine, URL-path token for a share link — is the app's choice. With `service`
119
+ * set it instead creates a NEW machine principal and binds the key to it (the folded service
120
+ * token); `principalId` is then the new principal's durable handle.
121
+ */
122
+ async issueKey(req: Request, input: IssueKeyRequest): Promise<IssuedKey | IssueFailure> {
123
+ const { credential, requestId } = readCredentials(req)
124
+ const result = await this.binding.issueKey({
125
+ app: this.appId,
126
+ credential,
127
+ requestId,
128
+ service: input.service,
129
+ principalId: input.principalId,
130
+ permissions: input.permissions,
131
+ label: input.label,
132
+ expiresAt: input.expiresAt,
133
+ })
134
+ if (result.ok) {
135
+ return { ok: true, token: result.token, id: result.id, ...(result.principalId === undefined ? {} : { principalId: result.principalId }) }
136
+ }
137
+ return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
138
+ }
139
+
140
+ /**
141
+ * Sign a stateless passthrough access token (audit-only, TTL-bounded, NOT revocable). Forwards the
142
+ * REQUESTER's credentials as the issuer (same delegation rule as `issueKey`). The holder carries the
143
+ * returned JWT directly as `Authorization: Bearer`; apps verify it locally with no propustka
144
+ * round-trip. There is nothing to revoke — it is TTL-only by design.
145
+ */
146
+ async issueJwt(req: Request, input: IssueJwtRequest): Promise<IssuedJwt | IssueFailure> {
147
+ const { credential, requestId } = readCredentials(req)
148
+ const result = await this.binding.issueJwt({
149
+ app: this.appId,
150
+ credential,
151
+ requestId,
152
+ permissions: input.permissions,
153
+ label: input.label,
154
+ ttl: input.ttl,
155
+ })
156
+ if (result.ok) {
157
+ return { ok: true, token: result.token, expiresAt: result.expiresAt, id: result.id }
158
+ }
159
+ return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
160
+ }
161
+
162
+ /**
163
+ * Revoke an opaque `px_` credential (API key / share link) by its id. Forwards the CALLER's
164
+ * credentials as the authorizer — the IAM Worker resolves the caller server-side and enforces the
165
+ * rule (the original issuer, or anyone who could re-issue the grants, may revoke). Idempotent: a
166
+ * second revoke returns `{ ok: true, revoked: false }`. An unknown id → 404.
167
+ */
168
+ async revokeKey(req: Request, id: string): Promise<RevokedKey | RevokeFailure> {
169
+ const { credential, requestId } = readCredentials(req)
170
+ const result = await this.binding.revokeKey({ app: this.appId, credential, requestId, id })
171
+ if (result.ok) {
172
+ return { ok: true, revoked: result.revoked }
173
+ }
174
+ return { ok: false, reason: result.reason, status: revokeFailureStatus(result.reason) }
175
+ }
176
+ }
177
+
178
+ /** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
179
+ function issueFailureStatus(reason: IssueFailure['reason']): 401 | 403 {
180
+ return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
181
+ }
182
+
183
+ /** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
184
+ function listPrincipalsStatus(reason: ListPrincipalsFailure['reason']): 401 | 403 {
185
+ return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
186
+ }
187
+
188
+ /** missing/invalid → 401; not_found → 404; unknown_principal/disabled/not_allowed → 403. */
189
+ function revokeFailureStatus(reason: RevokeFailure['reason']): 401 | 403 | 404 {
190
+ if (reason === 'missing_token' || reason === 'invalid_token') return 401
191
+ if (reason === 'not_found') return 404
192
+ return 403
193
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Typed request-time errors + a small authorization helper.
3
+ *
4
+ * These are the shared structural HTTP-error contract `@fabrika/app` maps without any
5
+ * cross-package `instanceof`: each error exposes `{ httpStatus, type, message }` plus an optional
6
+ * `issues` (validation detail). A mapper reads those fields structurally — it never imports these
7
+ * classes — so these shapes and the framework reader are the single contract. They live in
8
+ * `@fabrika/auth` because `requirePermission` consumes an `AuthContext` (a client type).
9
+ *
10
+ * There is deliberately no login-bounce error here: since ADR-0007 the proxy 302s an unauthenticated
11
+ * browser, so an app never has to produce a login URL of its own.
12
+ */
13
+
14
+ import type { Scope } from '@fabrika/auth-core'
15
+ import type { AuthContext } from './types'
16
+
17
+ /**
18
+ * The structural HTTP-error shape. Anything thrown through a request pipeline that satisfies this can be
19
+ * mapped to a Response without an `instanceof` check: `httpStatus` → the status, `type` → the error
20
+ * envelope's `type`, `message` → its `message`, `issues` → optional detail.
21
+ */
22
+ export interface HttpError {
23
+ readonly httpStatus: number
24
+ readonly type: string
25
+ readonly message: string
26
+ readonly issues?: unknown
27
+ }
28
+
29
+ /** A caller is unauthenticated. `type: 'auth'`, status 401. */
30
+ export class UnauthenticatedError extends Error implements HttpError {
31
+ readonly httpStatus = 401
32
+ readonly type = 'auth'
33
+
34
+ constructor(message = 'authentication required') {
35
+ super(message)
36
+ this.name = 'UnauthenticatedError'
37
+ }
38
+ }
39
+
40
+ /** A resolved caller lacks the required permission. `type: 'forbidden'`, status 403. */
41
+ export class ForbiddenError extends Error implements HttpError {
42
+ readonly httpStatus = 403
43
+ readonly type = 'forbidden'
44
+
45
+ constructor(message = 'forbidden') {
46
+ super(message)
47
+ this.name = 'ForbiddenError'
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Assert the caller may perform `action` (optionally within `scope`). Throws `ForbiddenError` when
53
+ * `auth.can(action, scope)` is false; otherwise returns. The thrown error satisfies the structural
54
+ * `HttpError` contract, so a pipeline maps it to a 403 without importing this module.
55
+ */
56
+ export function requirePermission(auth: AuthContext, action: string, scope?: Scope): void {
57
+ if (!auth.can(action, scope)) {
58
+ throw new ForbiddenError(`missing permission: ${action}`)
59
+ }
60
+ }
package/src/iam.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `createIam` — the single request-time entry point apps use instead of hand-rolling auth.
3
+ *
4
+ * Since [ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md) the PROXY is the only
5
+ * thing that enforces: it matches the app's gates, resolves the credential, and injects a verified
6
+ * access token as `PROXY_TOKEN_HEADER`. What is left for an app is small and fixed:
7
+ * - `authenticate(request)` — read that header and verify it LOCALLY against IAM's published JWKS
8
+ * (signature, `iss`, `aud`, `exp`), then build an `AuthContext`. The header is never trusted blindly.
9
+ * - `redeemKey(token)` — redeem a share-link capability OFF the gate path. Those requests arrive
10
+ * through a `public`/`service` gate and the app redeems the capability itself; there is no proxy
11
+ * equivalent by design.
12
+ * - the MANAGEMENT surface (`listPrincipals` / `issueKey` / `issueJwt` / `revokeKey`).
13
+ *
14
+ * There is NO gate evaluation here, no session→token exchange, and no cookie is ever written.
15
+ *
16
+ * There is also NO local mode. The SDK verifies IAM-issued tokens and nothing else, everywhere. Local
17
+ * development runs the real stack (`bun run local:up`), where the proxy fronts each service and IAM
18
+ * authenticates through its own `LOCAL_DEV_LOGIN` bypass — one dev bypass in the system, owned by the
19
+ * service that owns identity.
20
+ */
21
+
22
+ import { API_KEY_PREFIX, type IamRpc, type MintFromKeyResult, PROXY_TOKEN_HEADER, TOKEN_REFRESH_SKEW_SECONDS } from '@fabrika/auth-core'
23
+ import { buildAuthContext, IamClient } from './client'
24
+ import { readRequestId } from './request'
25
+ import type {
26
+ AuthContext,
27
+ IssuedJwt,
28
+ IssuedKey,
29
+ IssueFailure,
30
+ IssueJwtRequest,
31
+ IssueKeyRequest,
32
+ ListPrincipalsFailure,
33
+ PrincipalList,
34
+ RevokedKey,
35
+ RevokeFailure,
36
+ } from './types'
37
+ import { TokenVerifier } from './verify'
38
+
39
+ // ── Public config surfaces ──────────────────────────────────────────────────────
40
+
41
+ /** The env `createIam` reads. An app's Worker `Env` satisfies it structurally (extra fields are fine). */
42
+ export interface IamEnv {
43
+ /** The IAM service binding. Typed as the `IamRpc` contract — never the IAM Worker. */
44
+ IAM?: IamRpc
45
+ /** IAM origin — the token `iss` this SDK verifies against. Canonicalized once in `createIam`. */
46
+ FABRIKA_IAM_URL?: string
47
+ /** Fallback app id when `opts.appId` is omitted. */
48
+ FABRIKA_APP_ID?: string
49
+ }
50
+
51
+ /** Options for `createIam`. `appId` falls back to `env.FABRIKA_APP_ID`. */
52
+ export interface CreateIamOptions {
53
+ /** The IAM app id (baked in so it can never be mistyped). Falls back to `env.FABRIKA_APP_ID`. */
54
+ appId?: string
55
+ }
56
+
57
+ // ── Authentication result ────────────────────────────────────────────────────────
58
+
59
+ /** Why an authentication attempt produced no `AuthContext`. */
60
+ export type AuthFailureReason =
61
+ /** No proxy-injected token on the request — a `public` gate, or nothing is in front of the app. */
62
+ | 'no_token'
63
+ /** A token was presented and did not verify: signature, `iss`, `aud`, `exp` or claim shape. */
64
+ | 'invalid_token'
65
+ /** The credential resolved to nobody — IAM says the principal is gone or disabled. */
66
+ | 'unknown_principal'
67
+ /** IAM could not be consulted at all. NOT a decided negative — an incident, and never a 401. */
68
+ | 'unavailable'
69
+
70
+ /**
71
+ * The outcome of `authenticate` / `redeemKey`. A DECIDED NEGATIVE (`invalid_token`,
72
+ * `unknown_principal`) is not the same as "could not consult IAM" (`unavailable`, 503) — the same
73
+ * three-state rule `@fabrika/proxy` implements. Neither method throws for either case.
74
+ */
75
+ export type AuthResult =
76
+ | { ok: true; context: AuthContext }
77
+ | { ok: false; reason: AuthFailureReason; status: 401 | 403 | 503 }
78
+
79
+ /** Failure half of `AuthResult` — what a caller maps to its own error envelope. */
80
+ export type AuthFailure = Extract<AuthResult, { ok: false }>
81
+
82
+ // ── Synthetic contexts ───────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * The ANONYMOUS context — no principal, NO permissions (`can` → false, `scopedTo` → [] = none),
86
+ * `audit` a no-op. What an app sets on a request the proxy admitted through a `public` gate: there is
87
+ * no token to verify, and nothing about the caller is known. An RPC procedure's `.require` on such a
88
+ * request would (correctly) 403.
89
+ */
90
+ export function anonymousContext(): AuthContext {
91
+ return {
92
+ ok: true,
93
+ principal: null,
94
+ can: () => false,
95
+ scopedTo: () => [],
96
+ audit: () => Promise.resolve(),
97
+ }
98
+ }
99
+
100
+ // ── Small helpers ────────────────────────────────────────────────────────────────
101
+
102
+ /** Hex SHA-256 — the key-cache index, so no plaintext credential is ever held in memory as a map key. */
103
+ async function digest(value: string): Promise<string> {
104
+ const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
105
+ let hex = ''
106
+ for (const byte of bytes) {
107
+ hex += byte.toString(16).padStart(2, '0')
108
+ }
109
+ return hex
110
+ }
111
+
112
+ /**
113
+ * Per-binding cache of the access token minted from a `px_` share credential, keyed by the credential's
114
+ * SHA-256 digest and bounded — a share link redeemed on every request would otherwise pin an unbounded
115
+ * number of plaintext credentials for the process lifetime.
116
+ */
117
+ const KEY_CACHE_MAX_ENTRIES = 256
118
+ const keyTokenCache = new WeakMap<IamRpc, Map<string, { token: string; expiresAt: number }>>()
119
+
120
+ function keyCacheFor(binding: IamRpc): Map<string, { token: string; expiresAt: number }> {
121
+ let cache = keyTokenCache.get(binding)
122
+ if (cache === undefined) {
123
+ cache = new Map()
124
+ keyTokenCache.set(binding, cache)
125
+ }
126
+ return cache
127
+ }
128
+
129
+ /** Insert with a hard bound, evicting the oldest entry (Map preserves insertion order). */
130
+ function rememberKey(cache: Map<string, { token: string; expiresAt: number }>, key: string, entry: { token: string; expiresAt: number }): void {
131
+ if (!cache.has(key) && cache.size >= KEY_CACHE_MAX_ENTRIES) {
132
+ const oldest = cache.keys().next()
133
+ if (oldest.done !== true) {
134
+ cache.delete(oldest.value)
135
+ }
136
+ }
137
+ cache.set(key, entry)
138
+ }
139
+
140
+ // ── Iam ──────────────────────────────────────────────────────────────────────────
141
+
142
+ /** Internal construction config — `createIam` assembles this; apps never build it directly. */
143
+ interface IamConfig {
144
+ management: IamClient
145
+ binding: IamRpc
146
+ verifier: TokenVerifier
147
+ appId: string
148
+ }
149
+
150
+ /**
151
+ * The request-time IAM surface. Build it with `createIam` (the only intended entry point — the
152
+ * constructor takes an internal config). Bundles the management methods, token verification and
153
+ * share-link redemption, all bound to the env + app id resolved at construction.
154
+ */
155
+ export class Iam {
156
+ private readonly management: IamClient
157
+ private readonly binding: IamRpc
158
+ private readonly verifier: TokenVerifier
159
+ private readonly appId: string
160
+
161
+ /** @internal — use `createIam`. */
162
+ constructor(config: IamConfig) {
163
+ this.management = config.management
164
+ this.binding = config.binding
165
+ this.verifier = config.verifier
166
+ this.appId = config.appId
167
+ }
168
+
169
+ // ── Management surface (delegated to the real client) ─────────────────────────
170
+
171
+ listPrincipals(req: Request): Promise<PrincipalList | ListPrincipalsFailure> {
172
+ return this.management.listPrincipals(req)
173
+ }
174
+
175
+ issueKey(req: Request, input: IssueKeyRequest): Promise<IssuedKey | IssueFailure> {
176
+ return this.management.issueKey(req, input)
177
+ }
178
+
179
+ issueJwt(req: Request, input: IssueJwtRequest): Promise<IssuedJwt | IssueFailure> {
180
+ return this.management.issueJwt(req, input)
181
+ }
182
+
183
+ revokeKey(req: Request, id: string): Promise<RevokedKey | RevokeFailure> {
184
+ return this.management.revokeKey(req, id)
185
+ }
186
+
187
+ // ── Authentication ────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Resolve the caller from the token the PROXY injected, verifying it locally against the JWKS.
191
+ * Never throws: an absent token, a token that does not verify, and an unreachable IAM are three
192
+ * distinct `ok: false` results.
193
+ */
194
+ authenticate(request: Request): Promise<AuthResult> {
195
+ const token = request.headers.get(PROXY_TOKEN_HEADER)?.trim() ?? ''
196
+ if (token === '') {
197
+ return Promise.resolve({ ok: false, reason: 'no_token', status: 401 })
198
+ }
199
+ return this.contextFor(token, readRequestId(request))
200
+ }
201
+
202
+ /**
203
+ * Redeem an opaque `px_` share credential (or a passthrough JWT) into an `AuthContext` OFF the gate
204
+ * path — the seam a capability/share link uses, where the token rides a query param or a cookie
205
+ * rather than satisfying a gate. A `px_` key is exchanged once via `mintFromKey` and cached per
206
+ * binding; anything else is verified locally with no IAM call. Never throws — map a failure to a 404
207
+ * so the surface does not reveal whether the resource exists.
208
+ */
209
+ async redeemKey(token: string): Promise<AuthResult> {
210
+ const requestId = crypto.randomUUID()
211
+ if (!token.startsWith(API_KEY_PREFIX)) {
212
+ // A passthrough JWT — verify locally, no IAM call.
213
+ return await this.contextFor(token, requestId)
214
+ }
215
+
216
+ const cache = keyCacheFor(this.binding)
217
+ const index = await digest(token)
218
+ const now = Math.floor(Date.now() / 1000)
219
+ const cached = cache.get(index)
220
+ if (cached !== undefined && cached.expiresAt - now > TOKEN_REFRESH_SKEW_SECONDS) {
221
+ const result = await this.contextFor(cached.token, requestId)
222
+ if (result.ok || result.reason === 'unavailable') {
223
+ // Unverifiable because IAM is down is NOT evidence against the cached token — say so and stop.
224
+ return result
225
+ }
226
+ // A cached token that no longer verifies (rotated key, revoked issuer) is dropped, not trusted.
227
+ cache.delete(index)
228
+ }
229
+
230
+ let minted: MintFromKeyResult
231
+ try {
232
+ minted = await this.binding.mintFromKey({ app: this.appId, key: token, requestId })
233
+ } catch {
234
+ // A transport failure is "we could not consult IAM", never a decided negative. Never log the
235
+ // error object: it can carry the credential or a request URL.
236
+ return { ok: false, reason: 'unavailable', status: 503 }
237
+ }
238
+ if (!minted.ok) {
239
+ return minted.reason === 'invalid_key'
240
+ ? { ok: false, reason: 'invalid_token', status: 401 }
241
+ : { ok: false, reason: 'unknown_principal', status: 403 }
242
+ }
243
+ const result = await this.contextFor(minted.token, requestId)
244
+ if (result.ok) {
245
+ rememberKey(cache, index, { token: minted.token, expiresAt: minted.expiresAt })
246
+ }
247
+ return result
248
+ }
249
+
250
+ // ── Internals ─────────────────────────────────────────────────────────────────
251
+
252
+ /** Verify one token and wrap its claims in the `permits`-backed `AuthContext`. */
253
+ private async contextFor(token: string, requestId: string): Promise<AuthResult> {
254
+ const verified = await this.verifier.verify(token)
255
+ if (!verified.ok) {
256
+ return verified.reason === 'unavailable'
257
+ ? { ok: false, reason: 'unavailable', status: 503 }
258
+ : { ok: false, reason: 'invalid_token', status: 401 }
259
+ }
260
+ return { ok: true, context: buildAuthContext(this.binding, this.appId, verified.claims, requestId) }
261
+ }
262
+ }
263
+
264
+ // ── createIam ──────────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * The single request-time entry point. Reads `env.IAM` and the IAM environment names, and returns an
268
+ * `Iam` bound to them. Throws if the app id, the binding or the issuer is missing or unusable —
269
+ * there is no mode in which any of the three is optional.
270
+ */
271
+ export function createIam(env: IamEnv, opts: CreateIamOptions = {}): Iam {
272
+ const appId = opts.appId ?? env.FABRIKA_APP_ID
273
+ if (appId === undefined || appId === '') {
274
+ throw new Error('createIam: app id is required — pass opts.appId or set env.FABRIKA_APP_ID')
275
+ }
276
+
277
+ const binding = env.IAM
278
+ if (binding === undefined) {
279
+ throw new Error('createIam: the IAM service binding is missing (env.IAM) — check the IAM ServiceReference.')
280
+ }
281
+ const issuer = canonicalIssuer(env.FABRIKA_IAM_URL)
282
+ return new Iam({
283
+ management: new IamClient(binding, appId),
284
+ binding,
285
+ verifier: new TokenVerifier(binding, issuer, appId),
286
+ appId,
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Canonicalize the issuer ONCE, at the boundary. It is a byte-exact contract — it is the token's `iss`,
292
+ * and jose compares it verbatim — so `https://iam.test/` and `https://iam.test` must not be two
293
+ * different issuers depending on which call site happened to trim.
294
+ */
295
+ function canonicalIssuer(raw: string | undefined): string {
296
+ if (raw === undefined || raw.trim() === '') {
297
+ throw new Error('createIam: FABRIKA_IAM_URL is missing — it is the issuer every token is verified against.')
298
+ }
299
+ let url: URL
300
+ try {
301
+ url = new URL(raw.trim())
302
+ } catch {
303
+ throw new Error('createIam: FABRIKA_IAM_URL is not an absolute URL.')
304
+ }
305
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
306
+ throw new Error('createIam: FABRIKA_IAM_URL must be an http(s) origin.')
307
+ }
308
+ return url.origin
309
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ // @fabrika/auth — the app-facing IAM SDK (the only package published to npm).
2
+ //
3
+ // Depends ONLY on @fabrika/auth-core: the binding is typed as the `IamRpc` contract (so the SDK
4
+ // never imports the IAM service), and `can()`/`scopedTo()` reuse core's `permits`/`matchAction`.
5
+ //
6
+ // Enforcement lives in @fabrika/proxy (ADR-0007). Nothing exported here can evaluate a gate.
7
+
8
+ export { IamClient } from './client'
9
+ export { applyScope } from './scope'
10
+ // The typed HTTP errors + the per-resource authorization helper a per-path gate cannot express.
11
+ export { ForbiddenError, requirePermission, UnauthenticatedError } from './errors'
12
+ export type { HttpError } from './errors'
13
+ // Request-time surface: the single `createIam` entry point (verification + share-link redemption +
14
+ // the management methods) and the anonymous context an app sets on a request the proxy admitted
15
+ // through a `public` gate.
16
+ export { anonymousContext, createIam, Iam } from './iam'
17
+ export type { AuthFailure, AuthFailureReason, AuthResult, CreateIamOptions, IamEnv } from './iam'
18
+ // The shared middleware contract consumed by `@fabrika/app`. Owned here so an app pipeline needs no
19
+ // duplicate compatibility interface; the SDK itself no longer ships any middleware.
20
+ export type { AuthCarrier, Middleware } from './middleware'
21
+ // The HTTP transport for the SAME `IamRpc` contract, for a runtime with no Cloudflare service binding
22
+ // (a long-running Bun/Node process). Drops into anywhere a binding goes — `createIam`, `IamClient`,
23
+ // `env.IAM` — because the contract is unchanged and only the transport differs.
24
+ export { HttpIamRpc, IamTransportError } from './rpc-http'
25
+ export type { FetchLike, HttpIamRpcOptions } from './rpc-http'
26
+ // Local verification of the proxy-injected access token. `createIam` wires it for you; it is exported
27
+ // for an app that resolves a token outside the `Iam` surface.
28
+ export { TokenVerifier } from './verify'
29
+ export type { VerifyResult } from './verify'
30
+ // Deploy-time helper: reconcile an app's declared AppSchema (authz vocabulary) into IAM (an
31
+ // HTTP admin call, NOT over the service binding). Import from a deploy/provisioning step.
32
+ export { reconcileSchema, ReconcileSchemaError } from './provision'
33
+ export type { ReconcileSchemaOptions } from './provision'
34
+ export type {
35
+ AuthContext,
36
+ IssuedJwt,
37
+ IssuedKey,
38
+ IssueFailure,
39
+ IssueJwtRequest,
40
+ IssueKeyRequest,
41
+ ListPrincipalsFailure,
42
+ PrincipalIdentity,
43
+ PrincipalList,
44
+ RevokedKey,
45
+ RevokeFailure,
46
+ } from './types'
47
+
48
+ // The header the proxy injects its verified token on. Re-exported so an app never spells it out.
49
+ export { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
50
+
51
+ // Re-export from core so apps need only depend on the SDK: DomainEvent (one event shape),
52
+ // IamRpc (the binding contract — apps type their `env.IAM` as IamRpc without importing core),
53
+ // Scope (the `{ type, value }` coordinate apps pass to `can()`), the AppSchema vocabulary types
54
+ // apps DECLARE in `fabrika.schema.ts` for `reconcileSchema()`, and the per-path gate types
55
+ // (`AppGates` & co.) apps DECLARE for the PROXY manifest — never for in-process enforcement.
56
+ export type {
57
+ AppActionDef,
58
+ AppGates,
59
+ AppSchema,
60
+ AppScopeDef,
61
+ CredentialLocation,
62
+ DomainEvent,
63
+ GateKind,
64
+ GateRule,
65
+ IamRpc,
66
+ PrincipalListItem,
67
+ RoleDef,
68
+ Scope,
69
+ } from '@fabrika/auth-core'