@fabrika/auth 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/auth",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,9 @@
26
26
  "test": "bun test"
27
27
  },
28
28
  "dependencies": {
29
- "@fabrika/auth-core": "^0.2.0",
30
- "@fabrika/jwks": "^0.2.0",
29
+ "zod": "^3.25.76",
30
+ "@fabrika/auth-core": "^0.3.1",
31
+ "@fabrika/jwks": "^0.3.1",
31
32
  "jose": "^5.9.0"
32
33
  }
33
34
  }
@@ -0,0 +1,34 @@
1
+ import { type AppSchema, isActionAllowed } from '@fabrika/auth-core'
2
+ import { z } from 'zod'
3
+
4
+ /** Quote an untrusted fragment for an error message a run log may carry. */
5
+ const quote = (value: string): string => `\`${value.slice(0, 80)}\``
6
+
7
+ const scopeSchema = z.object({ type: z.string().min(1), label: z.string().min(1).optional() })
8
+ const actionSchema = z.object({ action: z.string().min(1), description: z.string().min(1).optional() })
9
+ const roleSchema = z.object({ name: z.string().min(1), description: z.string().min(1).optional(), permissions: z.array(z.string().min(1)) })
10
+ // `roles[key] = …` would reach the prototype setter, so `__proto__` is refused rather than assigned.
11
+ const roleKeySchema = z.string().min(1).refine((key) => key !== '__proto__')
12
+
13
+ /**
14
+ * An app's authz vocabulary, as an app declares it and IAM reconciles it. Every role permission must
15
+ * name an action the same body declares — IAM's own rule, checked wherever the vocabulary is read.
16
+ */
17
+ export const appSchemaSchema = z.object({
18
+ scopes: z.array(scopeSchema),
19
+ actions: z.array(actionSchema),
20
+ roles: z.record(roleKeySchema, roleSchema),
21
+ }).superRefine((schema, ctx) => {
22
+ const catalog = schema.actions.map((action) => action.action)
23
+ for (const [key, role] of Object.entries(schema.roles)) {
24
+ for (const [index, pattern] of role.permissions.entries()) {
25
+ if (!isActionAllowed(pattern, catalog)) {
26
+ ctx.addIssue({
27
+ code: 'custom',
28
+ path: ['roles', key, 'permissions', index],
29
+ message: `role ${quote(key)} references unknown action: ${quote(pattern)}`,
30
+ })
31
+ }
32
+ }
33
+ }
34
+ }) satisfies z.ZodType<AppSchema, z.ZodTypeDef, unknown>
package/src/client.ts CHANGED
@@ -1,26 +1,14 @@
1
1
  import type { AccessTokenClaims, DomainEvent, IamRpc, PermissionEntry, Scope } from '@fabrika/auth-core'
2
2
  import { permits, scopedValues } from '@fabrika/auth-core'
3
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'
4
+ import type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
17
5
 
18
6
  // ── AuthContext (real) ─────────────────────────────────────────────────────────
19
7
 
20
8
  /**
21
9
  * The real `AuthContext`. `can`/`scopedTo` are pure functions over the resolved permission array (no
22
10
  * 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
11
+ * session / API key) and an ANONYMOUS one (a standalone share link) — the latter has
24
12
  * `principal = null`; `audit` then attributes to the credential label + token id, principal null.
25
13
  */
26
14
  class RealAuthContext implements AuthContext {
@@ -32,9 +20,9 @@ class RealAuthContext implements AuthContext {
32
20
  private readonly permissions: PermissionEntry[],
33
21
  private readonly requestId: string,
34
22
  readonly principal: PrincipalIdentity | null,
35
- /** Audit actor label — the principal's label, or the credential/jwt label. */
23
+ /** Audit actor label — the principal's label, or the credential label. */
36
24
  private readonly auditLabel: string,
37
- /** The credential/token id, for the audit linkage of an anonymous caller (null when bound). */
25
+ /** The credential id, for the audit linkage of an anonymous caller (null when bound). */
38
26
  private readonly anonymousTokenId: string | null,
39
27
  ) {}
40
28
 
@@ -68,8 +56,8 @@ class RealAuthContext implements AuthContext {
68
56
 
69
57
  /**
70
58
  * 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,
59
+ * uses (the proxy-injected token). The SDK has verified the token LOCALLY; this wraps its claims in the
60
+ * same `permits`-backed context the RPC path returns,
73
61
  * so `can()` / `scopedTo()` / `audit()` behave identically. A token with no `ptype` is anonymous
74
62
  * (`principal: null`).
75
63
  */
@@ -109,85 +97,9 @@ export class IamClient {
109
97
  }
110
98
  return { ok: false, reason: result.reason, status: listPrincipalsStatus(result.reason) }
111
99
  }
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
100
  }
182
101
 
183
102
  /** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
184
103
  function listPrincipalsStatus(reason: ListPrincipalsFailure['reason']): 401 | 403 {
185
104
  return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
186
105
  }
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 CHANGED
@@ -7,7 +7,7 @@
7
7
  * classes — so these shapes and the framework reader are the single contract. They live in
8
8
  * `@fabrika/auth` because `requirePermission` consumes an `AuthContext` (a client type).
9
9
  *
10
- * There is deliberately no login-bounce error here: since ADR-0007 the proxy 302s an unauthenticated
10
+ * There is deliberately no login-bounce error here: since ADR-0022 the proxy 302s an unauthenticated
11
11
  * browser, so an app never has to produce a login URL of its own.
12
12
  */
13
13
 
package/src/iam.ts CHANGED
@@ -1,15 +1,12 @@
1
1
  /**
2
2
  * `createIam` — the single request-time entry point apps use instead of hand-rolling auth.
3
3
  *
4
- * Since [ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md) the PROXY is the only
4
+ * Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
5
5
  * thing that enforces: it matches the app's gates, resolves the credential, and injects a verified
6
6
  * access token as `PROXY_TOKEN_HEADER`. What is left for an app is small and fixed:
7
7
  * - `authenticate(request)` — read that header and verify it LOCALLY against IAM's published JWKS
8
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`).
9
+ * - `listPrincipals` — the app's people directory.
13
10
  *
14
11
  * There is NO gate evaluation here, no session→token exchange, and no cookie is ever written.
15
12
  *
@@ -19,21 +16,10 @@
19
16
  * service that owns identity.
20
17
  */
21
18
 
22
- import { API_KEY_PREFIX, type IamRpc, type MintFromKeyResult, PROXY_TOKEN_HEADER, TOKEN_REFRESH_SKEW_SECONDS } from '@fabrika/auth-core'
19
+ import { type IamRpc, PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
23
20
  import { buildAuthContext, IamClient } from './client'
24
21
  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'
22
+ import type { AuthContext, ListPrincipalsFailure, PrincipalList } from './types'
37
23
  import { TokenVerifier } from './verify'
38
24
 
39
25
  // ── Public config surfaces ──────────────────────────────────────────────────────
@@ -68,9 +54,9 @@ export type AuthFailureReason =
68
54
  | 'unavailable'
69
55
 
70
56
  /**
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.
57
+ * The outcome of `authenticate`. A DECIDED NEGATIVE (`invalid_token`, `unknown_principal`) is not the
58
+ * same as "could not consult IAM" (`unavailable`, 503) — the same three-state rule `@fabrika/proxy`
59
+ * implements. `authenticate` throws for neither case.
74
60
  */
75
61
  export type AuthResult =
76
62
  | { ok: true; context: AuthContext }
@@ -97,46 +83,6 @@ export function anonymousContext(): AuthContext {
97
83
  }
98
84
  }
99
85
 
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
86
  // ── Iam ──────────────────────────────────────────────────────────────────────────
141
87
 
142
88
  /** Internal construction config — `createIam` assembles this; apps never build it directly. */
@@ -149,8 +95,8 @@ interface IamConfig {
149
95
 
150
96
  /**
151
97
  * 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.
98
+ * constructor takes an internal config). Bundles token verification and `listPrincipals`, both bound
99
+ * to the env + app id resolved at construction.
154
100
  */
155
101
  export class Iam {
156
102
  private readonly management: IamClient
@@ -172,18 +118,6 @@ export class Iam {
172
118
  return this.management.listPrincipals(req)
173
119
  }
174
120
 
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
121
  // ── Authentication ────────────────────────────────────────────────────────────
188
122
 
189
123
  /**
@@ -199,54 +133,6 @@ export class Iam {
199
133
  return this.contextFor(token, readRequestId(request))
200
134
  }
201
135
 
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
136
  // ── Internals ─────────────────────────────────────────────────────────────────
251
137
 
252
138
  /** Verify one token and wrap its claims in the `permits`-backed `AuthContext`. */
package/src/index.ts CHANGED
@@ -3,15 +3,15 @@
3
3
  // Depends ONLY on @fabrika/auth-core: the binding is typed as the `IamRpc` contract (so the SDK
4
4
  // never imports the IAM service), and `can()`/`scopedTo()` reuse core's `permits`/`matchAction`.
5
5
  //
6
- // Enforcement lives in @fabrika/proxy (ADR-0007). Nothing exported here can evaluate a gate.
6
+ // Enforcement lives in @fabrika/proxy (ADR-0022). Nothing exported here can evaluate a gate.
7
7
 
8
8
  export { IamClient } from './client'
9
9
  export { applyScope } from './scope'
10
10
  // The typed HTTP errors + the per-resource authorization helper a per-path gate cannot express.
11
11
  export { ForbiddenError, requirePermission, UnauthenticatedError } from './errors'
12
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
13
+ // Request-time surface: the single `createIam` entry point (verification + `listPrincipals`) and the
14
+ // anonymous context an app sets on a request the proxy admitted
15
15
  // through a `public` gate.
16
16
  export { anonymousContext, createIam, Iam } from './iam'
17
17
  export type { AuthFailure, AuthFailureReason, AuthResult, CreateIamOptions, IamEnv } from './iam'
@@ -30,20 +30,10 @@ export type { VerifyResult } from './verify'
30
30
  // Deploy-time helper: reconcile an app's declared AppSchema (authz vocabulary) into IAM (an
31
31
  // HTTP admin call, NOT over the service binding). Import from a deploy/provisioning step.
32
32
  export { reconcileSchema, ReconcileSchemaError } from './provision'
33
+ // The one schema for an app's declared vocabulary; every reader of an `AppSchema` parses with it.
34
+ export { appSchemaSchema } from './app-schema'
33
35
  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'
36
+ export type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
47
37
 
48
38
  // The header the proxy injects its verified token on. Re-exported so an app never spells it out.
49
39
  export { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
package/src/middleware.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * The shared middleware contract consumed by `@fabrika/app`. The auth package owns the shape so an
3
3
  * app's own auth middleware — the one that calls `iam.authenticate(request)` and stores the resolved
4
4
  * context — drops into the request pipeline without a duplicate compatibility interface. The SDK
5
- * itself ships no middleware: enforcement is the proxy's job (ADR-0007).
5
+ * itself ships no middleware: enforcement is the proxy's job (ADR-0022).
6
6
  *
7
7
  * A middleware runs in order; it may:
8
8
  * - mutate `ctx` (e.g. set `ctx.auth`),
package/src/provision.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // IAM Worker's DB always mirrors what the app actually checks at runtime. An app's FIRST schema
7
7
  // reconcile is also how it REGISTERS itself in IAM's app registry.
8
8
  //
9
- // The same deploy step optionally projects the app's RETURN ORIGINS (ADR-0021) — see
9
+ // The same deploy step optionally projects the app's RETURN ORIGINS (ADR-0022) — see
10
10
  // `returnOrigins` below. That is a SECOND call to a different endpoint, deliberately: the schema is
11
11
  // vocabulary the application declares, a return origin is a fact the control plane knows, and the
12
12
  // two must never travel in one body.
@@ -21,6 +21,11 @@
21
21
  // re-sending the same return-origin set.
22
22
 
23
23
  import type { AppSchema } from '@fabrika/auth-core'
24
+ import { z } from 'zod'
25
+
26
+ const adminErrorSchema = z.object({
27
+ error: z.union([z.string(), z.object({ message: z.string() }).transform(value => value.message)]),
28
+ })
24
29
 
25
30
  export interface ReconcileSchemaOptions {
26
31
  /** The IAM Worker's admin origin, e.g. `https://iam.example.com` (trailing slash ok). */
@@ -30,21 +35,18 @@ export interface ReconcileSchemaOptions {
30
35
  /** The app's declared vocabulary. */
31
36
  schema: AppSchema
32
37
  /**
33
- * Every origin IAM may hand this app a session at (ADR-0021), as known by the CONTROL PLANE.
38
+ * Every origin IAM may hand this app a session at (ADR-0022), as known by the CONTROL PLANE.
34
39
  *
35
40
  * Omit to leave the registry alone; a supplied set REPLACES it, so it must be the complete set for
36
41
  * the app and not just the environment being deployed. It is NEVER read off the `AppSchema`: an app
37
42
  * that could name its own return origin could name any origin, which is the thing the registry
38
- * exists to prevent. An empty array is a caller error — clearing the registry is its own admin
39
- * operation, not an accident of having nothing to send.
43
+ * exists to prevent. An empty array is a caller error: an app is never un-registered by having
44
+ * nothing to send.
40
45
  */
41
46
  returnOrigins?: readonly string[]
42
47
  /**
43
- * An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's CSRF
44
- * guard exempts BEARER-ONLY callers, and nothing else — a credential-less server-side call sends
45
- * neither `Origin` nor `Referer` and is refused whatever the admin-origin registry holds. The
46
- * exemption rests on one fact, that a browser never attaches `Authorization` by itself; no such
47
- * fact covers a credential-less request, and `resolveAdmin` would read it as a global admin.
48
+ * An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's
49
+ * `/admin/*` accepts only a bearer and refuses a credential-less call in every environment.
48
50
  */
49
51
  adminKey?: string
50
52
  /** Cancels the in-flight request when schema reconciliation belongs to a deploy lifecycle. */
@@ -64,17 +66,8 @@ export class ReconcileSchemaError extends Error {
64
66
  * `/admin/rpc` answers `{ error: { type, message } }`. Null when neither shape is present.
65
67
  */
66
68
  function errorMessage(value: unknown): string | null {
67
- if (typeof value !== 'object' || value === null || !('error' in value)) {
68
- return null
69
- }
70
- const message = value.error
71
- if (typeof message === 'string') {
72
- return message
73
- }
74
- if (typeof message === 'object' && message !== null && 'message' in message && typeof message.message === 'string') {
75
- return message.message
76
- }
77
- return null
69
+ const result = adminErrorSchema.safeParse(value)
70
+ return result.success ? result.data.error : null
78
71
  }
79
72
 
80
73
  /**
package/src/request.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  // Reading the fabrika-native credential off the incoming Request, to forward to the IAM service's
2
- // management RPCs (issueKey / issueJwt / revokeKey / listPrincipals). Precedence: the token the PROXY
3
- // injected (the one it already verified), then an `Authorization: Bearer` (a machine `px_` key or a
4
- // passthrough JWT). None of this is a security boundary —
2
+ // `listPrincipals` RPC. Precedence: the token the PROXY injected (the one it already verified), then
3
+ // an `Authorization: Bearer` (a machine `px_` key). None of this is a security boundary —
5
4
  // IAM re-resolves and re-authorizes the caller server-side from this credential.
6
5
 
7
6
  import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
@@ -10,7 +9,7 @@ import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
10
9
  const RAY_HEADER = 'cf-ray'
11
10
 
12
11
  export interface ForwardedCredentials {
13
- /** The native credential (a `px_` key / passthrough JWT / `px_token`), or null when absent. */
12
+ /** The native credential (a `px_` key / `px_token`), or null when absent. */
14
13
  credential: string | null
15
14
  /** cf-ray, or a fresh UUID when absent (e.g. local dev). */
16
15
  requestId: string
package/src/rpc-http.ts CHANGED
@@ -42,26 +42,53 @@
42
42
  */
43
43
 
44
44
  import type {
45
+ ApproveDeviceAuthorizationInput,
46
+ ApproveDeviceAuthorizationResult,
45
47
  AuditInput,
48
+ DenyDeviceAuthorizationResult,
46
49
  IamRpc,
47
- IssueJwtInput,
48
- IssueJwtResult,
50
+ InspectDeviceAuthorizationInput,
51
+ InspectDeviceAuthorizationResult,
49
52
  IssueKeyInput,
50
53
  IssueKeyResult,
51
54
  Jwks,
55
+ ListOwnDeviceAuthorizationsInput,
56
+ ListOwnDeviceAuthorizationsResult,
52
57
  ListPrincipalsInput,
53
58
  ListPrincipalsResult,
54
59
  MintFromKeyInput,
55
60
  MintFromKeyResult,
56
61
  MintTokenInput,
57
62
  MintTokenResult,
58
- PrincipalListItem,
59
- PrincipalType,
60
- PublicJwk,
61
63
  RevokeKeyInput,
62
64
  RevokeKeyResult,
63
65
  } from '@fabrika/auth-core'
64
- import { booleanField, numberField, prop, stringField } from './json'
66
+ import {
67
+ parseApproveDeviceAuthorizationResult,
68
+ parseDenyDeviceAuthorizationResult,
69
+ parseInspectDeviceAuthorizationResult,
70
+ parseListOwnDeviceAuthorizationsResult,
71
+ } from '@fabrika/auth-core'
72
+ import { parseJwks } from '@fabrika/jwks'
73
+ import { z } from 'zod'
74
+
75
+ const mintSuccessSchema = z.object({ token: z.string().min(1), expiresAt: z.number().finite() })
76
+ const issueKeySuccessSchema = z.object({
77
+ token: z.string(),
78
+ id: z.string(),
79
+ principalId: z.string().optional().catch(undefined),
80
+ }).transform(({ principalId, ...result }) => ({ ...result, ...(principalId === undefined ? {} : { principalId }) }))
81
+ const revokeSuccessSchema = z.object({ revoked: z.boolean() })
82
+ const principalsSuccessSchema = z.object({
83
+ principals: z.array(z.object({
84
+ id: z.string(),
85
+ label: z.string(),
86
+ type: z.string().transform((value): 'service' | 'user' => value === 'service' ? 'service' : 'user'),
87
+ email: z.string().nullable().catch(null),
88
+ disabled: z.boolean().catch(false),
89
+ })),
90
+ })
91
+ const failureSchema = z.object({ ok: z.literal(false), reason: z.string().catch('') })
65
92
 
66
93
  /** Default per-call timeout. A hung IAM must fail the request, not hold a connection open forever. */
67
94
  const DEFAULT_TIMEOUT_MS = 5_000
@@ -98,6 +125,26 @@ export class IamTransportError extends Error {
98
125
  }
99
126
 
100
127
  export class HttpIamRpc implements IamRpc {
128
+ async inspectDeviceAuthorization(input: InspectDeviceAuthorizationInput): Promise<InspectDeviceAuthorizationResult> {
129
+ const result = parseInspectDeviceAuthorizationResult(await this.call('inspectDeviceAuthorization', input))
130
+ if (!result) throw new IamTransportError('inspectDeviceAuthorization')
131
+ return result
132
+ }
133
+ async approveDeviceAuthorization(input: ApproveDeviceAuthorizationInput): Promise<ApproveDeviceAuthorizationResult> {
134
+ const result = parseApproveDeviceAuthorizationResult(await this.call('approveDeviceAuthorization', input))
135
+ if (!result) throw new IamTransportError('approveDeviceAuthorization')
136
+ return result
137
+ }
138
+ async denyDeviceAuthorization(input: InspectDeviceAuthorizationInput): Promise<DenyDeviceAuthorizationResult> {
139
+ const result = parseDenyDeviceAuthorizationResult(await this.call('denyDeviceAuthorization', input))
140
+ if (!result) throw new IamTransportError('denyDeviceAuthorization')
141
+ return result
142
+ }
143
+ async listOwnDeviceAuthorizations(input: ListOwnDeviceAuthorizationsInput): Promise<ListOwnDeviceAuthorizationsResult> {
144
+ const result = parseListOwnDeviceAuthorizationsResult(await this.call('listOwnDeviceAuthorizations', input))
145
+ if (!result) throw new IamTransportError('listOwnDeviceAuthorizations')
146
+ return result
147
+ }
101
148
  private readonly origin: string
102
149
  private readonly key: string
103
150
  private readonly timeoutMs: number
@@ -134,28 +181,11 @@ export class HttpIamRpc implements IamRpc {
134
181
  if (failure !== null) {
135
182
  return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
136
183
  }
137
- const token = stringField(body, 'token')
138
- const id = stringField(body, 'id')
139
- if (token === undefined || id === undefined) {
184
+ const result = issueKeySuccessSchema.safeParse(body)
185
+ if (!result.success) {
140
186
  throw new IamTransportError('issueKey')
141
187
  }
142
- const principalId = stringField(body, 'principalId')
143
- return { ok: true, token, id, ...(principalId === undefined ? {} : { principalId }) }
144
- }
145
-
146
- async issueJwt(input: IssueJwtInput): Promise<IssueJwtResult> {
147
- const body = await this.call('issueJwt', input)
148
- const failure = readFailure(body)
149
- if (failure !== null) {
150
- return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
151
- }
152
- const token = stringField(body, 'token')
153
- const expiresAt = numberField(body, 'expiresAt')
154
- const id = stringField(body, 'id')
155
- if (token === undefined || expiresAt === undefined || id === undefined) {
156
- throw new IamTransportError('issueJwt')
157
- }
158
- return { ok: true, token, expiresAt, id }
188
+ return { ok: true, ...result.data }
159
189
  }
160
190
 
161
191
  async revokeKey(input: RevokeKeyInput): Promise<RevokeKeyResult> {
@@ -164,11 +194,11 @@ export class HttpIamRpc implements IamRpc {
164
194
  if (failure !== null) {
165
195
  return { ok: false, reason: oneOf(failure, [...ISSUE_REASONS, 'not_found'], 'not_allowed') }
166
196
  }
167
- const revoked = booleanField(body, 'revoked')
168
- if (revoked === undefined) {
197
+ const result = revokeSuccessSchema.safeParse(body)
198
+ if (!result.success) {
169
199
  throw new IamTransportError('revokeKey')
170
200
  }
171
- return { ok: true, revoked }
201
+ return { ok: true, ...result.data }
172
202
  }
173
203
 
174
204
  async listPrincipals(input: ListPrincipalsInput): Promise<ListPrincipalsResult> {
@@ -177,56 +207,22 @@ export class HttpIamRpc implements IamRpc {
177
207
  if (failure !== null) {
178
208
  return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
179
209
  }
180
- const raw = prop(body, 'principals')
181
- if (!Array.isArray(raw)) {
210
+ const result = principalsSuccessSchema.safeParse(body)
211
+ if (!result.success) {
182
212
  throw new IamTransportError('listPrincipals')
183
213
  }
184
- const principals: PrincipalListItem[] = []
185
- for (const item of raw) {
186
- const id = stringField(item, 'id')
187
- const label = stringField(item, 'label')
188
- const type = stringField(item, 'type')
189
- if (id === undefined || label === undefined || type === undefined) {
190
- throw new IamTransportError('listPrincipals')
191
- }
192
- const email = prop(item, 'email')
193
- principals.push({
194
- id,
195
- label,
196
- // Anything but the literal 'service' is a user — the same narrowing the binding transport does.
197
- type: principalType(type),
198
- email: typeof email === 'string' ? email : null,
199
- disabled: booleanField(item, 'disabled') ?? false,
200
- })
201
- }
202
- return { ok: true, principals }
214
+ return { ok: true, ...result.data }
203
215
  }
204
216
 
205
217
  async getJwks(): Promise<Jwks> {
206
218
  // `getJwks` takes no input, so an empty object body is the natural call (the server tolerates a
207
219
  // wholly empty body too). It goes through `/rpc/*` like every other method rather than the public
208
220
  // `/.well-known/jwks.json`, so ONE key and ONE surface serve the whole contract.
209
- const body = await this.call('getJwks', {})
210
- const rawKeys = prop(body, 'keys')
211
- if (!Array.isArray(rawKeys)) {
221
+ const jwks = parseJwks(await this.call('getJwks', {}))
222
+ if (jwks === null) {
212
223
  throw new IamTransportError('getJwks')
213
224
  }
214
- const keys: PublicJwk[] = []
215
- for (const raw of rawKeys) {
216
- const kty = stringField(raw, 'kty')
217
- if (kty === undefined) {
218
- throw new IamTransportError('getJwks')
219
- }
220
- const key: PublicJwk = { kty }
221
- for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
222
- const value = stringField(raw, field)
223
- if (value !== undefined) {
224
- key[field] = value
225
- }
226
- }
227
- keys.push(key)
228
- }
229
- return { keys }
225
+ return jwks
230
226
  }
231
227
 
232
228
  async audit(event: AuditInput): Promise<void> {
@@ -271,32 +267,28 @@ export class HttpIamRpc implements IamRpc {
271
267
  }
272
268
  }
273
269
 
274
- /** The five delegation-path failure reasons `issueKey` / `issueJwt` / `listPrincipals` share. */
270
+ /** The five delegation-path failure reasons `issueKey` / `revokeKey` / `listPrincipals` share. */
275
271
  const ISSUE_REASONS = ['missing_token', 'invalid_token', 'unknown_principal', 'disabled', 'not_allowed'] as const
276
272
 
277
273
  /** Distinguishes "the body was not JSON" from a body that decoded to something the readers reject. */
278
274
  const MALFORMED = Symbol('malformed')
279
275
 
280
276
  /**
281
- * The `reason` of a decided negative, or null when the body is an `ok: true` result. A body that is
282
- * neither (no `ok`, or `ok: false` with no reason) is a contract violation, not a denial — the caller
283
- * then fails its own success decode and raises `IamTransportError`, which is the fail-closed answer.
277
+ * Only literal false selects denial; missing or malformed reasons use the operation's deny fallback.
278
+ * Success fields are decoded independently of `ok`, preserving the SDK's tolerant wire contract.
284
279
  */
285
280
  function readFailure(body: unknown): string | null {
286
- if (prop(body, 'ok') !== false) {
287
- return null
288
- }
289
- return stringField(body, 'reason') ?? ''
281
+ const result = failureSchema.safeParse(body)
282
+ return result.success ? result.data.reason : null
290
283
  }
291
284
 
292
285
  /** The `{ ok: true, token, expiresAt }` half both mint methods share. */
293
286
  function readMintSuccess(body: unknown, method: string): { ok: true; token: string; expiresAt: number } {
294
- const token = stringField(body, 'token')
295
- const expiresAt = numberField(body, 'expiresAt')
296
- if (token === undefined || token === '' || expiresAt === undefined) {
287
+ const result = mintSuccessSchema.safeParse(body)
288
+ if (!result.success) {
297
289
  throw new IamTransportError(method)
298
290
  }
299
- return { ok: true, token, expiresAt }
291
+ return { ok: true, ...result.data }
300
292
  }
301
293
 
302
294
  /**
@@ -312,7 +304,3 @@ function oneOf<T extends string>(value: string, allowed: readonly T[], fallback:
312
304
  }
313
305
  return fallback
314
306
  }
315
-
316
- function principalType(value: string): PrincipalType {
317
- return value === 'service' ? 'service' : 'user'
318
- }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DomainEvent, IssueKeyServiceSpec, KeyGrant, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
1
+ import type { DomainEvent, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
2
2
 
3
3
  /**
4
4
  * The resolved caller's identity — who Access says you are, as the IAM Worker recorded
@@ -19,7 +19,7 @@ export interface PrincipalIdentity {
19
19
  //
20
20
  // Every method returns a discriminated union whose members all carry an `ok` field,
21
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
22
+ // `AuthContext` / `PrincipalList` surfaces; the ok:false members are
23
23
  // the typed failures below, each carrying the HTTP status the app should return.
24
24
  //
25
25
  // The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
@@ -35,7 +35,7 @@ export interface AuthContext {
35
35
  readonly ok: true
36
36
  /**
37
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
38
+ * standalone (no-principal) share link. For stamping domain rows and
39
39
  * display — NOT a permission surface (authorization stays `can` / `scopedTo`, which work the same
40
40
  * either way). Stamp `created_by` with `principal?.id ?? null`.
41
41
  */
@@ -61,38 +61,6 @@ export interface AuthContext {
61
61
  audit(event: DomainEvent): Promise<void>
62
62
  }
63
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
64
  /** Successful `listPrincipals` — the app's people directory (user principals). */
97
65
  export interface PrincipalList {
98
66
  readonly ok: true
@@ -101,61 +69,9 @@ export interface PrincipalList {
101
69
 
102
70
  // ── Failures ─────────────────────────────────────────────────────────────────
103
71
 
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
72
  /** `listPrincipals` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
119
73
  export interface ListPrincipalsFailure {
120
74
  readonly ok: false
121
75
  reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
122
76
  status: 401 | 403
123
77
  }
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 CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Local access-token verification — the only thing this SDK still does with a credential.
3
3
  *
4
- * Since [ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md) the PROXY is the only
4
+ * Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
5
5
  * enforcement point: it matches the app's gates, resolves the credential, and injects the verified
6
6
  * token as `PROXY_TOKEN_HEADER`. The app must NOT trust that header blindly, so every token is
7
7
  * re-verified here against IAM's published JWKS — signature, `iss`, `aud`, `exp` — before it can
package/src/json.ts DELETED
@@ -1,31 +0,0 @@
1
- /**
2
- * Tiny structural readers for untrusted JSON — here, an IAM HTTP response body. They let the decoders
3
- * in `rpc-http.ts` narrow without `as` casts: read a field, check its runtime type, proceed.
4
- *
5
- * Mirrors `@fabrika/auth-core`'s internal `json.ts` (not exported from its entry point) and
6
- * `@fabrika/proxy`'s copy. Three tiny functions duplicated three times is cheaper than widening a
7
- * published package's public API to share them.
8
- */
9
-
10
- /** Read a property off an unknown value (undefined when absent / not an object). */
11
- export function prop(value: unknown, key: string): unknown {
12
- if (typeof value !== 'object' || value === null) {
13
- return undefined
14
- }
15
- return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
16
- }
17
-
18
- export function stringField(value: unknown, key: string): string | undefined {
19
- const v = prop(value, key)
20
- return typeof v === 'string' ? v : undefined
21
- }
22
-
23
- export function numberField(value: unknown, key: string): number | undefined {
24
- const v = prop(value, key)
25
- return typeof v === 'number' && Number.isFinite(v) ? v : undefined
26
- }
27
-
28
- export function booleanField(value: unknown, key: string): boolean | undefined {
29
- const v = prop(value, key)
30
- return typeof v === 'boolean' ? v : undefined
31
- }