@fabrika/auth 0.3.3 → 0.4.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.3.3",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,8 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "zod": "^3.25.76",
30
- "@fabrika/auth-core": "^0.3.3",
31
- "@fabrika/jwks": "^0.3.3",
30
+ "@fabrika/auth-core": "^0.4.1",
31
+ "@fabrika/jwks": "^0.4.1",
32
32
  "jose": "^5.9.0"
33
33
  }
34
34
  }
package/src/app-schema.ts CHANGED
@@ -1,25 +1,236 @@
1
- import { type AppSchema, isActionAllowed } from '@fabrika/auth-core'
1
+ import {
2
+ type AppSchema,
3
+ BUILTIN_ROLE_KEYS,
4
+ displayNameProblem,
5
+ isActionAllowed,
6
+ LOCALES,
7
+ mailTemplateProblems,
8
+ type MessageKind,
9
+ } from '@fabrika/auth-core'
2
10
  import { z } from 'zod'
3
11
 
4
12
  /** Quote an untrusted fragment for an error message a run log may carry. */
5
13
  const quote = (value: string): string => `\`${value.slice(0, 80)}\``
6
14
 
7
- const scopeSchema = z.object({ type: z.string().min(1), label: z.string().min(1).optional() })
15
+ // `record[key] = …` would reach the prototype setter, so `__proto__` is refused rather than assigned.
16
+ const keySchema = z.string().min(1).refine((key) => key !== '__proto__')
17
+
18
+ const scopeSchema = z.object({
19
+ type: z.string().min(1),
20
+ label: z.string().min(1).optional(),
21
+ in: z.string().min(1).optional(),
22
+ creator: z.string().min(1).optional(),
23
+ members: z.object({ manage: z.string().min(1), list: z.string().min(1) }).optional(),
24
+ directory: z.literal('labels').optional(),
25
+ lookups: z.array(z.string().min(1)).optional(),
26
+ })
8
27
  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__')
28
+ const roleSchema = z.object({
29
+ name: z.string().min(1),
30
+ description: z.string().min(1).optional(),
31
+ permissions: z.array(z.string().min(1)),
32
+ member: z.object({ scope: z.string().min(1), assignableBy: z.array(z.string().min(1)).optional() }).optional(),
33
+ external: z.literal(true).optional(),
34
+ })
35
+ /** A link lives at most a year; the mount's `maxDays` is the bound a contract states below that. */
36
+ const MAX_LINK_DAYS = 365
37
+ const linkSchema = z.object({
38
+ dimension: z.string().min(1),
39
+ grants: z.array(z.string().min(1)).min(1),
40
+ issuedBy: z.string().min(1),
41
+ maxDays: z.number().int().min(1).max(MAX_LINK_DAYS),
42
+ })
43
+ const delegationSchema = z.object({
44
+ action: z.string().min(1),
45
+ dimension: z.string().min(1),
46
+ purpose: z.enum(['members', 'creates']),
47
+ })
48
+
49
+ /** A job or webhook name of a contract; an `app` workload is named by the consuming application's id. */
50
+ const WORKLOAD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/
51
+ const APP_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
52
+ const workloadSchema = z.discriminatedUnion('kind', [
53
+ z.object({
54
+ kind: z.literal('job'),
55
+ name: z.string().regex(WORKLOAD_NAME),
56
+ actions: z.array(z.string().min(1)),
57
+ issues: z.string().min(1).optional(),
58
+ }),
59
+ z.object({ kind: z.literal('webhook'), name: z.string().regex(WORKLOAD_NAME), actions: z.array(z.string().min(1)) }),
60
+ z.object({ kind: z.literal('app'), name: z.string().regex(APP_ID), actions: z.array(z.string().min(1)) }),
61
+ ])
62
+
63
+ type Issue = { path: (string | number)[]; message: string }
64
+
65
+ /** The membership and link declarations IAM acts on. Every name must resolve inside the same body. */
66
+ function membershipIssues(schema: z.infer<typeof appSchemaShape>): Issue[] {
67
+ const issues: Issue[] = []
68
+ const catalog = new Set(schema.actions.map((action) => action.action))
69
+ const dimensions = new Map(schema.scopes.map((scope) => [scope.type, scope]))
70
+ const exactAction = (path: (string | number)[], action: string): void => {
71
+ if (!catalog.has(action)) issues.push({ path, message: `${quote(action)} is not an exact action of the catalog` })
72
+ }
73
+ const membershipRoleOf = (key: string, dimension: string): boolean => {
74
+ const role = Object.hasOwn(schema.roles, key) ? schema.roles[key] : undefined
75
+ return role?.member?.scope === dimension
76
+ }
77
+ // IAM counts an application-wide role (no `member`) held globally as an assigner in every value (ADR-0022 rule 6).
78
+ const assignerOf = (key: string, dimension: string): boolean => {
79
+ const role = Object.hasOwn(schema.roles, key) ? schema.roles[key] : undefined
80
+ return role !== undefined && !BUILTIN_ROLE_KEYS.includes(key) && (role.member === undefined || role.member.scope === dimension)
81
+ }
82
+
83
+ for (const [index, scope] of schema.scopes.entries()) {
84
+ if (scope.in !== undefined && (scope.in === scope.type || !dimensions.has(scope.in))) {
85
+ issues.push({ path: ['scopes', index, 'in'], message: `scope ${quote(scope.type)} is in an undeclared scope: ${quote(scope.in)}` })
86
+ }
87
+ if (scope.creator !== undefined && !membershipRoleOf(scope.creator, scope.type)) {
88
+ issues.push({
89
+ path: ['scopes', index, 'creator'],
90
+ message: `the creator of scope ${quote(scope.type)} must be a membership role of that scope: ${quote(scope.creator)}`,
91
+ })
92
+ }
93
+ if (scope.members !== undefined) {
94
+ exactAction(['scopes', index, 'members', 'manage'], scope.members.manage)
95
+ exactAction(['scopes', index, 'members', 'list'], scope.members.list)
96
+ }
97
+ if (scope.lookups !== undefined && scope.members === undefined) {
98
+ issues.push({ path: ['scopes', index, 'lookups'], message: `scope ${quote(scope.type)} declares lookups without members` })
99
+ }
100
+ for (const [lookup, action] of (scope.lookups ?? []).entries()) {
101
+ exactAction(['scopes', index, 'lookups', lookup], action)
102
+ }
103
+ }
104
+ if (schema.members !== undefined) {
105
+ exactAction(['members', 'manage'], schema.members.manage)
106
+ exactAction(['members', 'list'], schema.members.list)
107
+ }
108
+ if (schema.directory !== undefined && schema.members === undefined) {
109
+ issues.push({ path: ['directory'], message: 'the application declares a directory without members' })
110
+ }
111
+ for (const scope of schema.scopes) {
112
+ const seen = new Set<string>([scope.type])
113
+ for (let next = scope.in; next !== undefined; next = dimensions.get(next)?.in) {
114
+ if (seen.has(next)) {
115
+ issues.push({ path: ['scopes'], message: `scope ${quote(scope.type)} contains itself` })
116
+ break
117
+ }
118
+ seen.add(next)
119
+ }
120
+ }
121
+ for (const [key, role] of Object.entries(schema.roles)) {
122
+ if (BUILTIN_ROLE_KEYS.includes(key) && (role.member !== undefined || schema.contract === true)) {
123
+ issues.push({
124
+ path: ['roles', key, 'name'],
125
+ message: `role ${quote(key)} is a built-in IAM role; a membership role or a contract application role needs another key`,
126
+ })
127
+ }
128
+ if (role.external === true && role.member === undefined) {
129
+ issues.push({ path: ['roles', key, 'external'], message: `role ${quote(key)} is declared external and is not a membership role` })
130
+ }
131
+ for (const [index, assigner] of (role.member?.assignableBy ?? []).entries()) {
132
+ if (role.member !== undefined && !assignerOf(assigner, role.member.scope)) {
133
+ issues.push({
134
+ path: ['roles', key, 'member', 'assignableBy', index],
135
+ message: `role ${quote(key)} is assignable only by a membership role of its scope or an application-wide role, not ${quote(assigner)}`,
136
+ })
137
+ }
138
+ }
139
+ }
140
+ for (const [mount, link] of Object.entries(schema.links ?? {})) {
141
+ if (!dimensions.has(link.dimension)) {
142
+ issues.push({ path: ['links', mount, 'dimension'], message: `link mount ${quote(mount)} names an undeclared scope: ${quote(link.dimension)}` })
143
+ }
144
+ for (const [index, grant] of link.grants.entries()) exactAction(['links', mount, 'grants', index], grant)
145
+ exactAction(['links', mount, 'issuedBy'], link.issuedBy)
146
+ }
147
+ const workloads = new Set<string>()
148
+ for (const [index, workload] of (schema.workloads ?? []).entries()) {
149
+ if (schema.contract !== true) {
150
+ issues.push({ path: ['workloads', index, 'kind'], message: 'only a contract application declares workloads' })
151
+ }
152
+ const key = `${workload.kind}:${workload.name}`
153
+ if (workloads.has(key)) issues.push({ path: ['workloads', index, 'name'], message: `workload ${quote(key)} is declared twice` })
154
+ workloads.add(key)
155
+ for (const [position, action] of workload.actions.entries()) exactAction(['workloads', index, 'actions', position], action)
156
+ if (workload.kind === 'job' && workload.issues !== undefined) {
157
+ const link = Object.hasOwn(schema.links ?? {}, workload.issues) ? schema.links?.[workload.issues] : undefined
158
+ if (link === undefined) {
159
+ issues.push({
160
+ path: ['workloads', index, 'issues'],
161
+ message: `job ${quote(workload.name)} issues an undeclared link mount: ${quote(workload.issues)}`,
162
+ })
163
+ } else if (![link.issuedBy, ...link.grants].every((action) => workload.actions.includes(action))) {
164
+ issues.push({
165
+ path: ['workloads', index, 'issues'],
166
+ message: `job ${quote(workload.name)} issues links of ${quote(workload.issues)} without holding its issuedBy and every grant`,
167
+ })
168
+ }
169
+ }
170
+ }
171
+ for (const [endpoint, delegation] of Object.entries(schema.delegations ?? {})) {
172
+ const dimension = dimensions.get(delegation.dimension)
173
+ exactAction(['delegations', endpoint, 'action'], delegation.action)
174
+ if (dimension === undefined) {
175
+ issues.push({
176
+ path: ['delegations', endpoint, 'dimension'],
177
+ message: `endpoint ${quote(endpoint)} names an undeclared scope: ${quote(delegation.dimension)}`,
178
+ })
179
+ } else if (delegation.purpose === 'members' && dimension.members?.manage !== delegation.action) {
180
+ issues.push({
181
+ path: ['delegations', endpoint, 'action'],
182
+ message: `endpoint ${quote(endpoint)} manages members only with the members.manage action of ${quote(delegation.dimension)}`,
183
+ })
184
+ } else if (delegation.purpose === 'creates' && (dimension.creator === undefined || dimension.in !== undefined)) {
185
+ issues.push({
186
+ path: ['delegations', endpoint, 'dimension'],
187
+ message: `endpoint ${quote(endpoint)} creates ${quote(delegation.dimension)}, which needs a creator role and no containing scope`,
188
+ })
189
+ }
190
+ }
191
+ return issues
192
+ }
193
+
194
+ const appSchemaShape = z.object({
195
+ scopes: z.array(scopeSchema),
196
+ actions: z.array(actionSchema),
197
+ roles: z.record(keySchema, roleSchema),
198
+ contract: z.boolean().optional(),
199
+ links: z.record(keySchema, linkSchema).optional(),
200
+ delegations: z.record(keySchema, delegationSchema).optional(),
201
+ workloads: z.array(workloadSchema).optional(),
202
+ locale: z.enum(LOCALES).optional(),
203
+ displayName: z.string().superRefine((value, ctx) => {
204
+ const problem = displayNameProblem(value)
205
+ if (problem !== null) ctx.addIssue({ code: 'custom', message: `the display name ${problem}` })
206
+ }).optional(),
207
+ mail: z.object({ invitation: mailLocales('invitation'), enrollment: mailLocales('enrollment'), reset: mailLocales('reset') }).strict().partial()
208
+ .optional(),
209
+ members: z.object({ manage: z.string().min(1), list: z.string().min(1) }).optional(),
210
+ directory: z.literal('labels').optional(),
211
+ })
212
+
213
+ /** One message's templates by language, each refused with the same rule the contract build applies. */
214
+ function mailLocales(kind: MessageKind) {
215
+ const template = z.object({ subject: z.string(), text: z.string(), html: z.string().optional() }).strict()
216
+ return z.record(z.enum(LOCALES), template).superRefine((templates, ctx) => {
217
+ for (const [locale, value] of Object.entries(templates)) {
218
+ for (const problem of mailTemplateProblems(kind, value)) {
219
+ ctx.addIssue({ code: 'custom', path: [locale], message: `mail.${kind}.${locale}: ${problem}` })
220
+ }
221
+ }
222
+ })
223
+ }
12
224
 
13
225
  /**
14
226
  * 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.
227
+ * name an action the same body declares — IAM's own rule, checked wherever the vocabulary is read. A
228
+ * contract application's roles list exact actions, and a membership role names a declared scope. The
229
+ * membership and link declarations name only scopes, roles and exact actions of the same body.
16
230
  */
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) => {
231
+ export const appSchemaSchema = appSchemaShape.superRefine((schema, ctx) => {
22
232
  const catalog = schema.actions.map((action) => action.action)
233
+ const scopes = schema.scopes.map((scope) => scope.type)
23
234
  for (const [key, role] of Object.entries(schema.roles)) {
24
235
  for (const [index, pattern] of role.permissions.entries()) {
25
236
  if (!isActionAllowed(pattern, catalog)) {
@@ -28,7 +239,23 @@ export const appSchemaSchema = z.object({
28
239
  path: ['roles', key, 'permissions', index],
29
240
  message: `role ${quote(key)} references unknown action: ${quote(pattern)}`,
30
241
  })
242
+ } else if (schema.contract === true && pattern.includes('*')) {
243
+ ctx.addIssue({
244
+ code: 'custom',
245
+ path: ['roles', key, 'permissions', index],
246
+ message: `role ${quote(key)} of a contract application lists the wildcard ${quote(pattern)}; list exact actions`,
247
+ })
31
248
  }
32
249
  }
250
+ if (role.member !== undefined && !scopes.includes(role.member.scope)) {
251
+ ctx.addIssue({
252
+ code: 'custom',
253
+ path: ['roles', key, 'member', 'scope'],
254
+ message: `membership role ${quote(key)} names an undeclared scope: ${quote(role.member.scope)}`,
255
+ })
256
+ }
257
+ }
258
+ for (const issue of membershipIssues(schema)) {
259
+ ctx.addIssue({ code: 'custom', ...issue })
33
260
  }
34
261
  }) satisfies z.ZodType<AppSchema, z.ZodTypeDef, unknown>
package/src/client.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { AccessTokenClaims, DomainEvent, IamRpc, PermissionEntry, Scope } from '@fabrika/auth-core'
2
- import { permits, scopedValues } from '@fabrika/auth-core'
1
+ import type { AccessTokenClaims, DomainEvent, LinkTokenClaims, OAuthTokenClaims, PermissionEntry, Scope } from '@fabrika/auth-core'
2
+ import { isWorkloadKind, permits, scopedValues } from '@fabrika/auth-core'
3
3
  import { readCredentials } from './request'
4
- import type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
4
+ import type { AuthContext, IamBinding, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
5
5
 
6
6
  // ── AuthContext (real) ─────────────────────────────────────────────────────────
7
7
 
@@ -15,8 +15,9 @@ class RealAuthContext implements AuthContext {
15
15
  readonly ok = true
16
16
 
17
17
  constructor(
18
- private readonly binding: IamRpc,
19
- private readonly appId: string,
18
+ private readonly binding: IamBinding,
19
+ /** The verified token this context was built from — IAM reads the audit event's app from it. */
20
+ private readonly token: string,
20
21
  private readonly permissions: PermissionEntry[],
21
22
  private readonly requestId: string,
22
23
  readonly principal: PrincipalIdentity | null,
@@ -24,6 +25,8 @@ class RealAuthContext implements AuthContext {
24
25
  private readonly auditLabel: string,
25
26
  /** The credential id, for the audit linkage of an anonymous caller (null when bound). */
26
27
  private readonly anonymousTokenId: string | null,
28
+ readonly oauth: OAuthTokenClaims | undefined,
29
+ readonly link: LinkTokenClaims | undefined,
27
30
  ) {}
28
31
 
29
32
  can(action: string, scope?: Scope): boolean {
@@ -40,7 +43,7 @@ class RealAuthContext implements AuthContext {
40
43
 
41
44
  audit(event: DomainEvent): Promise<void> {
42
45
  return this.binding.audit({
43
- app: this.appId,
46
+ credential: this.token,
44
47
  requestId: this.requestId,
45
48
  principalId: this.principal?.id ?? null,
46
49
  principalLabel: this.auditLabel,
@@ -55,18 +58,30 @@ class RealAuthContext implements AuthContext {
55
58
  }
56
59
 
57
60
  /**
58
- * Build an `AuthContext` from a verified access token's claims — the seam every authentication path
59
- * uses (the proxy-injected token). The SDK has verified the token LOCALLY; this wraps its claims in the
61
+ * Build an `AuthContext` from a verified access token and its claims — the seam every authentication
62
+ * path uses (the proxy-injected token). The SDK has verified the token LOCALLY; this wraps its claims in the
60
63
  * same `permits`-backed context the RPC path returns,
61
64
  * so `can()` / `scopedTo()` / `audit()` behave identically. A token with no `ptype` is anonymous
62
65
  * (`principal: null`).
63
66
  */
64
- export function buildAuthContext(binding: IamRpc, appId: string, claims: AccessTokenClaims, requestId: string): AuthContext {
65
- const principal: PrincipalIdentity | null = claims.ptype === undefined
66
- ? null
67
- : { id: claims.sub, type: claims.ptype, label: claims.label ?? claims.sub }
67
+ export function buildAuthContext(binding: IamBinding, token: string, claims: AccessTokenClaims, requestId: string): AuthContext {
68
+ const type = claims.ptype
69
+ if (isWorkloadKind(type)) {
70
+ throw new Error('a workload token has no AuthContext')
71
+ }
72
+ const principal: PrincipalIdentity | null = type === undefined ? null : { id: claims.sub, type, label: claims.label ?? claims.sub }
68
73
  const auditLabel = claims.label ?? (principal === null ? `credential:${claims.sub}` : claims.sub)
69
- return new RealAuthContext(binding, appId, claims.perms, requestId, principal, auditLabel, principal === null ? claims.sub : null)
74
+ return new RealAuthContext(
75
+ binding,
76
+ token,
77
+ claims.perms,
78
+ requestId,
79
+ principal,
80
+ auditLabel,
81
+ principal === null ? claims.sub : null,
82
+ claims.oauth,
83
+ claims.link,
84
+ )
70
85
  }
71
86
 
72
87
  // ── IamClient ──────────────────────────────────────────────────────────────────
@@ -79,7 +94,7 @@ export function buildAuthContext(binding: IamRpc, appId: string, claims: AccessT
79
94
  */
80
95
  export class IamClient {
81
96
  constructor(
82
- private readonly binding: IamRpc,
97
+ private readonly binding: IamBinding,
83
98
  private readonly appId: string,
84
99
  ) {}
85
100
 
package/src/iam.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * `createIam` — the single request-time entry point apps use instead of hand-rolling auth.
3
3
  *
4
4
  * Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
5
- * thing that enforces: it matches the app's gates, resolves the credential, and injects a verified
5
+ * thing that enforces: it selects the endpoint, 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
9
  * - `listPrincipals` — the app's people directory.
10
10
  *
11
- * There is NO gate evaluation here, no session→token exchange, and no cookie is ever written.
11
+ * There is NO access decision here, no session→token exchange, and no cookie is ever written.
12
12
  *
13
13
  * There is also NO local mode. The SDK verifies IAM-issued tokens and nothing else, everywhere. Local
14
14
  * development runs the real stack (`bun run local:up`), where the proxy fronts each service and IAM
@@ -16,35 +16,40 @@
16
16
  * service that owns identity.
17
17
  */
18
18
 
19
- import { type IamRpc, PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
19
+ import { isOAuthResourceUri, isWorkloadKind, PROXY_TOKEN_HEADER, type TokenAudienceProfile } from '@fabrika/auth-core'
20
20
  import { buildAuthContext, IamClient } from './client'
21
21
  import { readRequestId } from './request'
22
- import type { AuthContext, ListPrincipalsFailure, PrincipalList } from './types'
22
+ import type { AuthContext, IamBinding, ListPrincipalsFailure, PrincipalList } from './types'
23
23
  import { TokenVerifier } from './verify'
24
24
 
25
25
  // ── Public config surfaces ──────────────────────────────────────────────────────
26
26
 
27
27
  /** The env `createIam` reads. An app's Worker `Env` satisfies it structurally (extra fields are fine). */
28
28
  export interface IamEnv {
29
- /** The IAM service binding. Typed as the `IamRpc` contract — never the IAM Worker. */
30
- IAM?: IamRpc
29
+ /** The IAM service binding, or `HttpIamRpc` off Cloudflare. Typed by the contract — never the IAM Worker. */
30
+ IAM?: IamBinding
31
31
  /** IAM origin — the token `iss` this SDK verifies against. Canonicalized once in `createIam`. */
32
32
  FABRIKA_IAM_ISSUER?: string
33
- /** Fallback app id when `opts.appId` is omitted. */
34
- FABRIKA_APP_ID?: string
33
+ /** The IAM app id `<app>@<env>` every deploy writes (`FABRIKA_IAM_APP_ID`); the fallback for `opts.appId`. */
34
+ FABRIKA_IAM_APP_ID?: string
35
35
  }
36
36
 
37
- /** Options for `createIam`. `appId` falls back to `env.FABRIKA_APP_ID`. */
37
+ /** Options for `createIam`. `appId` falls back to `env.FABRIKA_IAM_APP_ID`. */
38
38
  export interface CreateIamOptions {
39
- /** The IAM app id (baked in so it can never be mistyped). Falls back to `env.FABRIKA_APP_ID`. */
39
+ /** The IAM app id: a platform service bakes its own in; an application reads `env.FABRIKA_IAM_APP_ID`. */
40
40
  appId?: string
41
+ /**
42
+ * Which tokens `authenticate` accepts. Defaults to the app's native tokens; a resource server
43
+ * passes `{ kind: 'resource', app, resource }` to accept OAuth tokens minted for that resource.
44
+ */
45
+ tokenAudience?: TokenAudienceProfile
41
46
  }
42
47
 
43
48
  // ── Authentication result ────────────────────────────────────────────────────────
44
49
 
45
50
  /** Why an authentication attempt produced no `AuthContext`. */
46
51
  export type AuthFailureReason =
47
- /** No proxy-injected token on the request — a `public` gate, or nothing is in front of the app. */
52
+ /** No proxy-injected token on the request — an anonymous endpoint, or nothing is in front of the app. */
48
53
  | 'no_token'
49
54
  /** A token was presented and did not verify: signature, `iss`, `aud`, `exp` or claim shape. */
50
55
  | 'invalid_token'
@@ -69,7 +74,7 @@ export type AuthFailure = Extract<AuthResult, { ok: false }>
69
74
 
70
75
  /**
71
76
  * The ANONYMOUS context — no principal, NO permissions (`can` → false, `scopedTo` → [] = none),
72
- * `audit` a no-op. What an app sets on a request the proxy admitted through a `public` gate: there is
77
+ * `audit` a no-op. What an app sets on a request the proxy admitted through an anonymous endpoint: there is
73
78
  * no token to verify, and nothing about the caller is known. An RPC procedure's `.require` on such a
74
79
  * request would (correctly) 403.
75
80
  */
@@ -88,9 +93,10 @@ export function anonymousContext(): AuthContext {
88
93
  /** Internal construction config — `createIam` assembles this; apps never build it directly. */
89
94
  interface IamConfig {
90
95
  management: IamClient
91
- binding: IamRpc
96
+ binding: IamBinding
92
97
  verifier: TokenVerifier
93
98
  appId: string
99
+ tokenAudience: TokenAudienceProfile
94
100
  }
95
101
 
96
102
  /**
@@ -100,9 +106,10 @@ interface IamConfig {
100
106
  */
101
107
  export class Iam {
102
108
  private readonly management: IamClient
103
- private readonly binding: IamRpc
109
+ private readonly binding: IamBinding
104
110
  private readonly verifier: TokenVerifier
105
111
  private readonly appId: string
112
+ private readonly tokenAudience: TokenAudienceProfile
106
113
 
107
114
  /** @internal — use `createIam`. */
108
115
  constructor(config: IamConfig) {
@@ -110,6 +117,7 @@ export class Iam {
110
117
  this.binding = config.binding
111
118
  this.verifier = config.verifier
112
119
  this.appId = config.appId
120
+ this.tokenAudience = config.tokenAudience
113
121
  }
114
122
 
115
123
  // ── Management surface (delegated to the real client) ─────────────────────────
@@ -137,13 +145,17 @@ export class Iam {
137
145
 
138
146
  /** Verify one token and wrap its claims in the `permits`-backed `AuthContext`. */
139
147
  private async contextFor(token: string, requestId: string): Promise<AuthResult> {
140
- const verified = await this.verifier.verify(token)
148
+ const verified = await this.verifier.verify(token, this.tokenAudience)
141
149
  if (!verified.ok) {
142
150
  return verified.reason === 'unavailable'
143
151
  ? { ok: false, reason: 'unavailable', status: 503 }
144
152
  : { ok: false, reason: 'invalid_token', status: 401 }
145
153
  }
146
- return { ok: true, context: buildAuthContext(this.binding, this.appId, verified.claims, requestId) }
154
+ // A workload token is accepted by no mount (ADR-0022 "Principal kinds"), so no request carries one.
155
+ if (isWorkloadKind(verified.claims.ptype)) {
156
+ return { ok: false, reason: 'invalid_token', status: 401 }
157
+ }
158
+ return { ok: true, context: buildAuthContext(this.binding, token, verified.claims, requestId) }
147
159
  }
148
160
  }
149
161
 
@@ -155,9 +167,9 @@ export class Iam {
155
167
  * there is no mode in which any of the three is optional.
156
168
  */
157
169
  export function createIam(env: IamEnv, opts: CreateIamOptions = {}): Iam {
158
- const appId = opts.appId ?? env.FABRIKA_APP_ID
170
+ const appId = opts.appId ?? env.FABRIKA_IAM_APP_ID
159
171
  if (appId === undefined || appId === '') {
160
- throw new Error('createIam: app id is required — pass opts.appId or set env.FABRIKA_APP_ID')
172
+ throw new Error('createIam: app id is required — pass opts.appId or set env.FABRIKA_IAM_APP_ID')
161
173
  }
162
174
 
163
175
  const binding = env.IAM
@@ -165,11 +177,16 @@ export function createIam(env: IamEnv, opts: CreateIamOptions = {}): Iam {
165
177
  throw new Error('createIam: the IAM service binding is missing (env.IAM) — check the IAM ServiceReference.')
166
178
  }
167
179
  const issuer = canonicalIssuer(env.FABRIKA_IAM_ISSUER)
180
+ const tokenAudience = opts.tokenAudience ?? { kind: 'app', app: appId }
181
+ if (tokenAudience.app !== appId || (tokenAudience.kind === 'resource' && !isOAuthResourceUri(tokenAudience.resource))) {
182
+ throw new Error('createIam: tokenAudience must name this app and, for a resource, a valid OAuth resource URI.')
183
+ }
168
184
  return new Iam({
169
185
  management: new IamClient(binding, appId),
170
186
  binding,
171
187
  verifier: new TokenVerifier(binding, issuer, appId),
172
188
  appId,
189
+ tokenAudience,
173
190
  })
174
191
  }
175
192
 
package/src/index.ts CHANGED
@@ -3,25 +3,27 @@
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-0022). Nothing exported here can evaluate a gate.
6
+ // Enforcement lives in the proxy (ADR-0022). Nothing exported here decides whether a request reaches the app.
7
7
 
8
8
  export { IamClient } from './client'
9
9
  export { applyScope } from './scope'
10
- // The typed HTTP errors + the per-resource authorization helper a per-path gate cannot express.
10
+ // The typed HTTP errors + the per-resource authorization helper the endpoint table cannot express.
11
11
  export { ForbiddenError, requirePermission, UnauthenticatedError } from './errors'
12
12
  export type { HttpError } from './errors'
13
13
  // Request-time surface: the single `createIam` entry point (verification + `listPrincipals`) and the
14
14
  // anonymous context an app sets on a request the proxy admitted
15
- // through a `public` gate.
15
+ // through an anonymous endpoint.
16
16
  export { anonymousContext, createIam, Iam } from './iam'
17
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'
18
+ export type { AuthCarrier } from './types'
21
19
  // The HTTP transport for the SAME `IamRpc` contract, for a runtime with no Cloudflare service binding
22
20
  // (a long-running Bun/Node process). Drops into anywhere a binding goes — `createIam`, `IamClient`,
23
21
  // `env.IAM` — because the contract is unchanged and only the transport differs.
24
- export { HttpIamRpc, IamTransportError } from './rpc-http'
22
+ export { DecodedIamMembership, HttpIamMembership, HttpIamRpc, IamTransportError } from './rpc-http'
23
+ export type { IamMembershipMethod } from './rpc-http'
24
+ // The membership operations an application requests on its caller's behalf (scopes, members, share links).
25
+ export { createIamMembership, IamMembership } from './membership'
26
+ export type { IamMembershipEnv, MembershipRequestCaller } from './membership'
25
27
  export type { FetchLike, HttpIamRpcOptions } from './rpc-http'
26
28
  // Local verification of the proxy-injected access token. `createIam` wires it for you; it is exported
27
29
  // for an app that resolves a token outside the `Iam` surface.
@@ -33,27 +35,27 @@ export { reconcileSchema, ReconcileSchemaError } from './provision'
33
35
  // The one schema for an app's declared vocabulary; every reader of an `AppSchema` parses with it.
34
36
  export { appSchemaSchema } from './app-schema'
35
37
  export type { ReconcileSchemaOptions } from './provision'
36
- export type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
38
+ export type { AuthContext, IamBinding, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
37
39
 
38
40
  // The header the proxy injects its verified token on. Re-exported so an app never spells it out.
39
41
  export { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
40
42
 
41
43
  // Re-export from core so apps need only depend on the SDK: DomainEvent (one event shape),
42
- // IamRpc (the binding contract — apps type their `env.IAM` as IamRpc without importing core),
43
- // Scope (the `{ type, value }` coordinate apps pass to `can()`), the AppSchema vocabulary types
44
- // apps DECLARE in `fabrika.schema.ts` for `reconcileSchema()`, and the per-path gate types
45
- // (`AppGates` & co.) apps DECLARE for the PROXY manifest — never for in-process enforcement.
44
+ // IamRpc (the whole binding contract; an app types its `env.IAM` as the narrower `IamBinding`),
45
+ // Scope (the `{ type, value }` coordinate apps pass to `can()`), and the AppSchema vocabulary types
46
+ // apps DECLARE in `fabrika.schema.ts` for `reconcileSchema()`.
46
47
  export type {
47
48
  AppActionDef,
48
- AppGates,
49
49
  AppSchema,
50
50
  AppScopeDef,
51
- CredentialLocation,
52
51
  DomainEvent,
53
- GateKind,
54
- GateRule,
52
+ IamMembershipRpc,
55
53
  IamRpc,
54
+ LinkTokenClaims,
55
+ MembershipFailure,
56
+ OAuthTokenClaims,
56
57
  PrincipalListItem,
57
58
  RoleDef,
58
59
  Scope,
60
+ TokenAudienceProfile,
59
61
  } from '@fabrika/auth-core'
@@ -0,0 +1,110 @@
1
+ import type {
2
+ AssignMemberResult,
3
+ CreateScopeResult,
4
+ FindMemberResult,
5
+ IamMembershipRpc,
6
+ InvitationOptions,
7
+ IssueLinkResult,
8
+ ListAppMembersResult,
9
+ ListMembersResult,
10
+ MemberTarget,
11
+ RequestDelegationResult,
12
+ RevokeLinkResult,
13
+ RevokeMemberInput,
14
+ RevokeMemberResult,
15
+ Scope,
16
+ } from '@fabrika/auth-core'
17
+
18
+ /**
19
+ * The caller a membership request is made for: the verified token the proxy injected on THIS request, and
20
+ * the request's correlation id. IAM resolves the caller from the token and checks its live permissions; a
21
+ * delegation grant is bound to the request id, so both calls of one request carry the same one.
22
+ */
23
+ export interface MembershipRequestCaller {
24
+ readonly credential: string
25
+ readonly requestId: string
26
+ }
27
+
28
+ /** The env `createIamMembership` reads; an application's `env` satisfies it structurally. */
29
+ export interface IamMembershipEnv {
30
+ /** IAM's application entrypoint on Cloudflare, or `HttpIamRpc` off it. */
31
+ IAM?: IamMembershipRpc
32
+ FABRIKA_IAM_APP_ID?: string
33
+ }
34
+
35
+ /**
36
+ * The SDK half of the membership operations. It REQUESTS, on the caller's behalf: IAM decides, generates
37
+ * every value, writes every grant and issues every key. Nothing here mints, signs or names an actor, and
38
+ * every result is IAM's answer as it came.
39
+ */
40
+ export class IamMembership {
41
+ constructor(
42
+ private readonly binding: IamMembershipRpc,
43
+ private readonly appId: string,
44
+ ) {}
45
+
46
+ /** A single-use grant for one request to `endpoint`, which the app's schema lists as a membership endpoint. */
47
+ requestDelegation(caller: MembershipRequestCaller, endpoint: string): Promise<RequestDelegationResult> {
48
+ return this.binding.requestDelegation({ ...this.caller(caller), endpoint })
49
+ }
50
+
51
+ /** A new value of `dimension`, with the caller granted the dimension's creator role in it. */
52
+ createScope(caller: MembershipRequestCaller, delegation: string, dimension: string): Promise<CreateScopeResult> {
53
+ return this.binding.createScope({ ...this.caller(caller), delegation, dimension })
54
+ }
55
+
56
+ /** `invitation` goes with an address only: its label, message and resend (`InvitationOptions`). */
57
+ assignMember(
58
+ caller: MembershipRequestCaller,
59
+ delegation: string,
60
+ scope: Scope,
61
+ role: string,
62
+ member: MemberTarget,
63
+ invitation?: InvitationOptions,
64
+ ): Promise<AssignMemberResult> {
65
+ return this.binding.assignMember({ ...this.caller(caller), delegation, scope, role, member, ...(invitation === undefined ? {} : { invitation }) })
66
+ }
67
+
68
+ revokeMember(caller: MembershipRequestCaller, delegation: string, scope: Scope, member: RevokeMemberInput['member']): Promise<RevokeMemberResult> {
69
+ return this.binding.revokeMember({ ...this.caller(caller), delegation, scope, member })
70
+ }
71
+
72
+ listMembers(caller: MembershipRequestCaller, scope: Scope): Promise<ListMembersResult> {
73
+ return this.binding.listMembers({ ...this.caller(caller), scope })
74
+ }
75
+
76
+ /** One page of the application's own people; `after` is the previous page's `next`. */
77
+ listAppMembers(caller: MembershipRequestCaller, after?: string): Promise<ListAppMembersResult> {
78
+ return this.binding.listAppMembers({ ...this.caller(caller), ...(after === undefined ? {} : { after }) })
79
+ }
80
+
81
+ /** One principal among the members of `scope`, for an endpoint of `action` on, in or below its dimension. */
82
+ findMember(caller: MembershipRequestCaller, scope: Scope, principalId: string, action: string): Promise<FindMemberResult> {
83
+ return this.binding.findMember({ ...this.caller(caller), scope, principalId, action })
84
+ }
85
+
86
+ /** A share link of `mount` for `chain[0]`; `chain` is the object and every value containing it, outward. */
87
+ issueLink(caller: MembershipRequestCaller, mount: string, chain: Scope[], days: number): Promise<IssueLinkResult> {
88
+ return this.binding.issueLink({ ...this.caller(caller), mount, chain, days })
89
+ }
90
+
91
+ revokeLink(caller: MembershipRequestCaller, id: string): Promise<RevokeLinkResult> {
92
+ return this.binding.revokeLink({ ...this.caller(caller), id })
93
+ }
94
+
95
+ private caller(caller: MembershipRequestCaller): { app: string; credential: string; requestId: string } {
96
+ return { app: this.appId, credential: caller.credential, requestId: caller.requestId }
97
+ }
98
+ }
99
+
100
+ /** Build the membership client from the env. Throws when the app id or the binding is missing. */
101
+ export function createIamMembership(env: IamMembershipEnv, opts: { appId?: string } = {}): IamMembership {
102
+ const appId = opts.appId ?? env.FABRIKA_IAM_APP_ID
103
+ if (appId === undefined || appId === '') {
104
+ throw new Error('createIamMembership: app id is required — pass opts.appId or set env.FABRIKA_IAM_APP_ID')
105
+ }
106
+ if (env.IAM === undefined) {
107
+ throw new Error('createIamMembership: the IAM binding is missing (env.IAM)')
108
+ }
109
+ return new IamMembership(env.IAM, appId)
110
+ }
package/src/rpc-http.ts CHANGED
@@ -44,24 +44,44 @@
44
44
  import type {
45
45
  ApproveDeviceAuthorizationInput,
46
46
  ApproveDeviceAuthorizationResult,
47
+ AssignMemberInput,
48
+ AssignMemberResult,
47
49
  AuditInput,
50
+ CreateScopeInput,
51
+ CreateScopeResult,
48
52
  DenyDeviceAuthorizationResult,
53
+ FindMemberInput,
54
+ FindMemberResult,
55
+ IamMembershipRpc,
49
56
  IamRpc,
50
57
  InspectDeviceAuthorizationInput,
51
58
  InspectDeviceAuthorizationResult,
52
59
  IssueKeyInput,
53
60
  IssueKeyResult,
61
+ IssueLinkInput,
62
+ IssueLinkResult,
54
63
  Jwks,
64
+ ListAppMembersInput,
65
+ ListAppMembersResult,
66
+ ListMembersInput,
67
+ ListMembersResult,
55
68
  ListOwnDeviceAuthorizationsInput,
56
69
  ListOwnDeviceAuthorizationsResult,
57
70
  ListPrincipalsInput,
58
71
  ListPrincipalsResult,
72
+ MembershipFailure,
59
73
  MintFromKeyInput,
60
74
  MintFromKeyResult,
61
75
  MintTokenInput,
62
76
  MintTokenResult,
77
+ RequestDelegationInput,
78
+ RequestDelegationResult,
63
79
  RevokeKeyInput,
64
80
  RevokeKeyResult,
81
+ RevokeLinkInput,
82
+ RevokeLinkResult,
83
+ RevokeMemberInput,
84
+ RevokeMemberResult,
65
85
  } from '@fabrika/auth-core'
66
86
  import {
67
87
  parseApproveDeviceAuthorizationResult,
@@ -89,6 +109,50 @@ const principalsSuccessSchema = z.object({
89
109
  })),
90
110
  })
91
111
  const failureSchema = z.object({ ok: z.literal(false), reason: z.string().catch('') })
112
+ const scopeSchema = z.object({ type: z.string(), value: z.string() })
113
+ // Every membership success is a `{ ok: true, … }` envelope; a body without one is not an answer.
114
+ const okEnvelope = { ok: z.literal(true) }
115
+ const delegationSuccessSchema = z.object({ ...okEnvelope, grant: z.string().min(1), expiresAt: z.number().finite() })
116
+ const createScopeSuccessSchema = z.object({ ...okEnvelope, scope: scopeSchema, role: z.string() })
117
+ const assignSuccessSchema = z.union([
118
+ z.object({ ...okEnvelope, assigned: z.object({ principalId: z.string(), role: z.string() }) }),
119
+ z.object({ ...okEnvelope, invited: z.object({ invitationId: z.string(), role: z.string(), expiresAt: z.number().finite() }) }),
120
+ ])
121
+ const affiliationSchema = z.enum(['internal', 'external'])
122
+ const listMembersSuccessSchema = z.object({
123
+ ...okEnvelope,
124
+ members: z.array(z.object({
125
+ principalId: z.string(),
126
+ label: z.string().nullable(),
127
+ email: z.string().nullable(),
128
+ // An IAM from before the field answers none: the member's last sign-in is then unknown.
129
+ lastSignInAt: z.number().finite().nullable().default(null),
130
+ roles: z.array(z.string()),
131
+ disabled: z.boolean(),
132
+ affiliation: affiliationSchema,
133
+ })),
134
+ invitations: z.array(z.object({ id: z.string(), email: z.string(), role: z.string(), expiresAt: z.number().finite() })),
135
+ })
136
+ const listAppMembersSuccessSchema = z.object({
137
+ ...okEnvelope,
138
+ members: z.array(z.object({
139
+ principalId: z.string(),
140
+ label: z.string().nullable(),
141
+ email: z.string().nullable(),
142
+ lastSignInAt: z.number().finite().nullable(),
143
+ roles: z.array(z.object({ role: z.string(), scope: scopeSchema.nullable() })),
144
+ disabled: z.boolean(),
145
+ affiliation: affiliationSchema,
146
+ })),
147
+ next: z.string().nullable(),
148
+ })
149
+ const findMemberSuccessSchema = z.object({
150
+ ...okEnvelope,
151
+ member: z.object({ principalId: z.string(), label: z.string().nullable(), disabled: z.boolean(), affiliation: affiliationSchema }).nullable(),
152
+ })
153
+ const issueLinkSuccessSchema = z.object({ ...okEnvelope, key: z.string().min(1), id: z.string(), expiresAt: z.number().finite() })
154
+ const revokedSuccessSchema = z.object(okEnvelope)
155
+ const membershipMessageSchema = z.object({ message: z.string().catch('') })
92
156
 
93
157
  /** Default per-call timeout. A hung IAM must fail the request, not hold a connection open forever. */
94
158
  const DEFAULT_TIMEOUT_MS = 5_000
@@ -102,7 +166,10 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
102
166
  export interface HttpIamRpcOptions {
103
167
  /** The IAM service origin, e.g. `http://iam:3000` on a private network (trailing slash tolerated). */
104
168
  origin: string
105
- /** The shared secret gating `/rpc/*` (`FABRIKA_IAM_RPC_KEY` on the IAM side). NEVER logged. */
169
+ /**
170
+ * The bearer: the installation's shared secret gating `/rpc/*` for `HttpIamRpc`, or an application's own
171
+ * membership transport key for `HttpIamMembership` (`FABRIKA_IAM_RPC_KEY` in each). NEVER logged.
172
+ */
106
173
  key: string
107
174
  /** Per-call timeout in milliseconds. */
108
175
  timeoutMs?: number
@@ -124,7 +191,7 @@ export class IamTransportError extends Error {
124
191
  }
125
192
  }
126
193
 
127
- export class HttpIamRpc implements IamRpc {
194
+ export class HttpIamRpc implements IamRpc, IamMembershipRpc {
128
195
  async inspectDeviceAuthorization(input: InspectDeviceAuthorizationInput): Promise<InspectDeviceAuthorizationResult> {
129
196
  const result = parseInspectDeviceAuthorizationResult(await this.call('inspectDeviceAuthorization', input))
130
197
  if (!result) throw new IamTransportError('inspectDeviceAuthorization')
@@ -145,23 +212,20 @@ export class HttpIamRpc implements IamRpc {
145
212
  if (!result) throw new IamTransportError('listOwnDeviceAuthorizations')
146
213
  return result
147
214
  }
148
- private readonly origin: string
149
- private readonly key: string
150
- private readonly timeoutMs: number
151
- private readonly fetchImpl: FetchLike
215
+ private readonly endpoint: TransportEndpoint
152
216
 
153
217
  constructor(options: HttpIamRpcOptions) {
154
- this.origin = options.origin.replace(/\/+$/, '')
155
- this.key = options.key
156
- this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
157
- this.fetchImpl = options.fetch ?? globalThis.fetch
218
+ this.endpoint = transportEndpoint(options, '/rpc/')
158
219
  }
159
220
 
160
221
  async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
161
222
  const body = await this.call('mintToken', input)
162
223
  const failure = readFailure(body)
163
224
  if (failure !== null) {
164
- return { ok: false, reason: oneOf(failure, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
225
+ return {
226
+ ok: false,
227
+ reason: oneOf(failure, ['no_session', 'invalid_session', 'unknown_principal', 'disabled', 'token_too_large'], 'invalid_session'),
228
+ }
165
229
  }
166
230
  return readMintSuccess(body, 'mintToken')
167
231
  }
@@ -170,7 +234,7 @@ export class HttpIamRpc implements IamRpc {
170
234
  const body = await this.call('mintFromKey', input)
171
235
  const failure = readFailure(body)
172
236
  if (failure !== null) {
173
- return { ok: false, reason: oneOf(failure, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
237
+ return { ok: false, reason: oneOf(failure, ['invalid_key', 'unknown_principal', 'disabled', 'token_too_large'], 'invalid_key') }
174
238
  }
175
239
  return readMintSuccess(body, 'mintFromKey')
176
240
  }
@@ -214,6 +278,44 @@ export class HttpIamRpc implements IamRpc {
214
278
  return { ok: true, ...result.data }
215
279
  }
216
280
 
281
+ readonly #membership = new DecodedIamMembership((method, input) => this.call(method, input))
282
+
283
+ requestDelegation(input: RequestDelegationInput): Promise<RequestDelegationResult> {
284
+ return this.#membership.requestDelegation(input)
285
+ }
286
+
287
+ createScope(input: CreateScopeInput): Promise<CreateScopeResult> {
288
+ return this.#membership.createScope(input)
289
+ }
290
+
291
+ assignMember(input: AssignMemberInput): Promise<AssignMemberResult> {
292
+ return this.#membership.assignMember(input)
293
+ }
294
+
295
+ revokeMember(input: RevokeMemberInput): Promise<RevokeMemberResult> {
296
+ return this.#membership.revokeMember(input)
297
+ }
298
+
299
+ listMembers(input: ListMembersInput): Promise<ListMembersResult> {
300
+ return this.#membership.listMembers(input)
301
+ }
302
+
303
+ listAppMembers(input: ListAppMembersInput): Promise<ListAppMembersResult> {
304
+ return this.#membership.listAppMembers(input)
305
+ }
306
+
307
+ findMember(input: FindMemberInput): Promise<FindMemberResult> {
308
+ return this.#membership.findMember(input)
309
+ }
310
+
311
+ issueLink(input: IssueLinkInput): Promise<IssueLinkResult> {
312
+ return this.#membership.issueLink(input)
313
+ }
314
+
315
+ revokeLink(input: RevokeLinkInput): Promise<RevokeLinkResult> {
316
+ return this.#membership.revokeLink(input)
317
+ }
318
+
217
319
  async getJwks(): Promise<Jwks> {
218
320
  // `getJwks` takes no input, so an empty object body is the natural call (the server tolerates a
219
321
  // wholly empty body too). It goes through `/rpc/*` like every other method rather than the public
@@ -232,38 +334,138 @@ export class HttpIamRpc implements IamRpc {
232
334
  await this.send('audit', event)
233
335
  }
234
336
 
235
- /** POST one method and return its decoded JSON body. Throws `IamTransportError` on any transport fault. */
236
- private async call(method: string, input: unknown): Promise<unknown> {
237
- const response = await this.send(method, input)
238
- const body: unknown = await response.json().catch(() => MALFORMED)
239
- if (body === MALFORMED) {
240
- throw new IamTransportError(method)
241
- }
242
- return body
243
- }
244
-
245
- private async send(method: string, input: unknown): Promise<Response> {
246
- let response: Response
247
- try {
248
- response = await this.fetchImpl(`${this.origin}/rpc/${method}`, {
249
- method: 'POST',
250
- headers: {
251
- 'content-type': 'application/json',
252
- accept: 'application/json',
253
- authorization: `Bearer ${this.key}`,
254
- },
255
- body: JSON.stringify(input),
256
- signal: AbortSignal.timeout(this.timeoutMs),
257
- })
258
- } catch {
259
- // Swallow the cause deliberately: a fetch error stringifies the request URL and, on some
260
- // runtimes, request detail. Only the method name is ever surfaced.
261
- throw new IamTransportError(method)
262
- }
263
- if (!response.ok) {
264
- throw new IamTransportError(method)
265
- }
266
- return response
337
+ private call(method: string, input: unknown): Promise<unknown> {
338
+ return callJson(this.endpoint, method, input)
339
+ }
340
+
341
+ private send(method: string, input: unknown): Promise<Response> {
342
+ return post(this.endpoint, method, input)
343
+ }
344
+ }
345
+
346
+ /** Where one transport POSTs: `{origin}{prefix}<method>` with the key as the bearer. */
347
+ interface TransportEndpoint {
348
+ readonly origin: string
349
+ readonly prefix: string
350
+ readonly key: string
351
+ readonly timeoutMs: number
352
+ readonly fetch: FetchLike
353
+ }
354
+
355
+ function transportEndpoint(options: HttpIamRpcOptions, prefix: string): TransportEndpoint {
356
+ return {
357
+ origin: options.origin.replace(/\/+$/, ''),
358
+ prefix,
359
+ key: options.key,
360
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
361
+ fetch: options.fetch ?? globalThis.fetch,
362
+ }
363
+ }
364
+
365
+ /** POST one method and return its decoded JSON body. Throws `IamTransportError` on any transport fault. */
366
+ async function callJson(endpoint: TransportEndpoint, method: string, input: unknown): Promise<unknown> {
367
+ const response = await post(endpoint, method, input)
368
+ const body: unknown = await response.json().catch(() => MALFORMED)
369
+ if (body === MALFORMED) {
370
+ throw new IamTransportError(method)
371
+ }
372
+ return body
373
+ }
374
+
375
+ async function post(endpoint: TransportEndpoint, method: string, input: unknown): Promise<Response> {
376
+ let response: Response
377
+ try {
378
+ response = await endpoint.fetch(`${endpoint.origin}${endpoint.prefix}${method}`, {
379
+ method: 'POST',
380
+ headers: {
381
+ 'content-type': 'application/json',
382
+ accept: 'application/json',
383
+ authorization: `Bearer ${endpoint.key}`,
384
+ },
385
+ body: JSON.stringify(input),
386
+ signal: AbortSignal.timeout(endpoint.timeoutMs),
387
+ })
388
+ } catch {
389
+ // Swallow the cause deliberately: a fetch error stringifies the request URL and, on some
390
+ // runtimes, request detail. Only the method name is ever surfaced.
391
+ throw new IamTransportError(method)
392
+ }
393
+ if (!response.ok) {
394
+ throw new IamTransportError(method)
395
+ }
396
+ return response
397
+ }
398
+
399
+ /** The membership operations, by name, as a transport carries them. */
400
+ export type IamMembershipMethod = keyof IamMembershipRpc
401
+
402
+ /**
403
+ * `IamMembershipRpc` over any transport that answers each call with a JSON-shaped body: `HttpIamRpc`, or a Workers
404
+ * binding to IAM's application entrypoint. Every answer is decoded here, so a body that breaks the contract is
405
+ * `IamTransportError`, never a value of the declared type.
406
+ */
407
+ export class DecodedIamMembership implements IamMembershipRpc {
408
+ readonly #call: (method: IamMembershipMethod, input: unknown) => Promise<unknown>
409
+
410
+ constructor(call: (method: IamMembershipMethod, input: unknown) => Promise<unknown>) {
411
+ this.#call = call
412
+ }
413
+
414
+ async requestDelegation(input: RequestDelegationInput): Promise<RequestDelegationResult> {
415
+ const body = await this.#call('requestDelegation', input)
416
+ return readMembershipFailure(body) ?? decode(delegationSuccessSchema, body, 'requestDelegation')
417
+ }
418
+
419
+ async createScope(input: CreateScopeInput): Promise<CreateScopeResult> {
420
+ const body = await this.#call('createScope', input)
421
+ return readMembershipFailure(body) ?? decode(createScopeSuccessSchema, body, 'createScope')
422
+ }
423
+
424
+ async assignMember(input: AssignMemberInput): Promise<AssignMemberResult> {
425
+ const body = await this.#call('assignMember', input)
426
+ return readMembershipFailure(body) ?? decode(assignSuccessSchema, body, 'assignMember')
427
+ }
428
+
429
+ async revokeMember(input: RevokeMemberInput): Promise<RevokeMemberResult> {
430
+ const body = await this.#call('revokeMember', input)
431
+ return readMembershipFailure(body) ?? decode(revokedSuccessSchema, body, 'revokeMember')
432
+ }
433
+
434
+ async listMembers(input: ListMembersInput): Promise<ListMembersResult> {
435
+ const body = await this.#call('listMembers', input)
436
+ return readMembershipFailure(body) ?? decode(listMembersSuccessSchema, body, 'listMembers')
437
+ }
438
+
439
+ async listAppMembers(input: ListAppMembersInput): Promise<ListAppMembersResult> {
440
+ const body = await this.#call('listAppMembers', input)
441
+ return readMembershipFailure(body) ?? decode(listAppMembersSuccessSchema, body, 'listAppMembers')
442
+ }
443
+
444
+ async findMember(input: FindMemberInput): Promise<FindMemberResult> {
445
+ const body = await this.#call('findMember', input)
446
+ return readMembershipFailure(body) ?? decode(findMemberSuccessSchema, body, 'findMember')
447
+ }
448
+
449
+ async issueLink(input: IssueLinkInput): Promise<IssueLinkResult> {
450
+ const body = await this.#call('issueLink', input)
451
+ return readMembershipFailure(body) ?? decode(issueLinkSuccessSchema, body, 'issueLink')
452
+ }
453
+
454
+ async revokeLink(input: RevokeLinkInput): Promise<RevokeLinkResult> {
455
+ const body = await this.#call('revokeLink', input)
456
+ return readMembershipFailure(body) ?? decode(revokedSuccessSchema, body, 'revokeLink')
457
+ }
458
+ }
459
+
460
+ /**
461
+ * `IamMembershipRpc` over IAM's membership transport, `POST {origin}/membership/<method>` (`@fabrika/iam`'s
462
+ * `src/membership-http.ts`): what an application without an IAM binding calls, with its own membership transport key.
463
+ * IAM serves no `requestDelegation` there, because only the proxy obtains a grant; calling it is `IamTransportError`.
464
+ */
465
+ export class HttpIamMembership extends DecodedIamMembership {
466
+ constructor(options: HttpIamRpcOptions) {
467
+ const endpoint = transportEndpoint(options, '/membership/')
468
+ super((method, input) => callJson(endpoint, method, input))
267
469
  }
268
470
  }
269
471
 
@@ -282,6 +484,42 @@ function readFailure(body: unknown): string | null {
282
484
  return result.success ? result.data.reason : null
283
485
  }
284
486
 
487
+ const MEMBERSHIP_REASONS = [
488
+ 'missing_token',
489
+ 'invalid_token',
490
+ 'unknown_principal',
491
+ 'disabled',
492
+ 'not_allowed',
493
+ 'invalid_delegation',
494
+ 'not_found',
495
+ 'last_creator',
496
+ 'invitations_disabled',
497
+ 'delivery_failed',
498
+ 'internal_mailbox',
499
+ ] as const
500
+
501
+ /** A membership refusal off the wire. An unknown reason degrades to `not_allowed`, never to an allow. */
502
+ function readMembershipFailure(body: unknown): MembershipFailure | null {
503
+ const reason = readFailure(body)
504
+ if (reason === null) {
505
+ return null
506
+ }
507
+ if (reason === 'invalid_request') {
508
+ const message = membershipMessageSchema.safeParse(body)
509
+ return { ok: false, reason, message: message.success ? message.data.message : '' }
510
+ }
511
+ return { ok: false, reason: oneOf(reason, MEMBERSHIP_REASONS, 'not_allowed') }
512
+ }
513
+
514
+ /** Decode a success body, or treat IAM as unreachable: a body that breaks the contract is not an answer. */
515
+ function decode<T>(schema: z.ZodType<T, z.ZodTypeDef, unknown>, body: unknown, method: string): T {
516
+ const result = schema.safeParse(body)
517
+ if (!result.success) {
518
+ throw new IamTransportError(method)
519
+ }
520
+ return result.data
521
+ }
522
+
285
523
  /** The `{ ok: true, token, expiresAt }` half both mint methods share. */
286
524
  function readMintSuccess(body: unknown, method: string): { ok: true; token: string; expiresAt: number } {
287
525
  const result = mintSuccessSchema.safeParse(body)
package/src/types.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { DomainEvent, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
1
+ import type { DomainEvent, IamRpc, LinkTokenClaims, OAuthTokenClaims, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
2
+
3
+ /**
4
+ * The `IamRpc` members the SDK calls. A Cloudflare application's `IAM` binding is IAM's
5
+ * `PropustkaApplication` entrypoint, which serves these and has no `issueKey` or `revokeKey`.
6
+ */
7
+ export type IamBinding = Pick<IamRpc, 'getJwks' | 'listPrincipals' | 'audit'>
2
8
 
3
9
  /**
4
10
  * The resolved caller's identity — who Access says you are, as the IAM Worker recorded
@@ -40,6 +46,16 @@ export interface AuthContext {
40
46
  * either way). Stamp `created_by` with `principal?.id ?? null`.
41
47
  */
42
48
  readonly principal: PrincipalIdentity | null
49
+ /**
50
+ * The OAuth client, consented scopes and grant behind an OAuth resource token; absent for every
51
+ * native credential. `perms` are already narrowed by the scopes, so `can()` stays the authority.
52
+ */
53
+ readonly oauth?: OAuthTokenClaims
54
+ /**
55
+ * The share link behind a link key's token — its mount and the containment chain it is bound to —
56
+ * absent for every other credential. `principal` is then null.
57
+ */
58
+ readonly link?: LinkTokenClaims
43
59
  /**
44
60
  * Point check: may this principal do `action` (optionally within `scope`, an opaque
45
61
  * `{ type, value }` coordinate)? Scope-less → satisfied by GLOBAL permissions only; a
@@ -75,3 +91,8 @@ export interface ListPrincipalsFailure {
75
91
  reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
76
92
  status: 401 | 403
77
93
  }
94
+
95
+ /** A request context with a writable `auth` slot, set to the resolved `AuthContext` (or null for an anonymous caller). */
96
+ export interface AuthCarrier {
97
+ auth?: AuthContext | null
98
+ }
package/src/verify.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Local access-token verification — the only thing this SDK still does with a credential.
3
3
  *
4
4
  * Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
5
- * enforcement point: it matches the app's gates, resolves the credential, and injects the verified
5
+ * enforcement point: it selects the endpoint, 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
8
8
  * become an `AuthContext`.
@@ -10,19 +10,20 @@
10
10
  * The verification itself is `@fabrika/jwks`, shared byte-for-byte with the proxy's enforcement path.
11
11
  * What this file owns is the SDK's construction shape: the audience is fixed at construction, and the
12
12
  * key set plus the unknown-`kid` cooldown are cached PER BINDING in a module-level WeakMap rather than
13
- * per instance — `@fabrika/app` calls its `middleware(env)` factory on every request, so an SDK object
13
+ * per instance — a service may build one on every request, so an SDK object
14
14
  * built there is per-request while the binding lives for the isolate/process.
15
15
  */
16
16
 
17
- import type { IamRpc } from '@fabrika/auth-core'
17
+ import type { TokenAudienceProfile } from '@fabrika/auth-core'
18
18
  import { createJwksCache, type JwksCache, TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
19
+ import type { IamBinding } from './types'
19
20
 
20
21
  export type { VerifyResult }
21
22
 
22
23
  /** Key set + cooldown cached per binding, so they survive the per-request SDK objects built over them. */
23
- const jwksCache = new WeakMap<IamRpc, JwksCache>()
24
+ const jwksCache = new WeakMap<IamBinding, JwksCache>()
24
25
 
25
- function cacheFor(binding: IamRpc): JwksCache {
26
+ function cacheFor(binding: IamBinding): JwksCache {
26
27
  let cache = jwksCache.get(binding)
27
28
  if (cache === undefined) {
28
29
  cache = createJwksCache()
@@ -31,20 +32,23 @@ function cacheFor(binding: IamRpc): JwksCache {
31
32
  return cache
32
33
  }
33
34
 
34
- /** Verifies tokens for ONE (binding, issuer, audience) triple. Cheap to construct; the cache is shared. */
35
+ /**
36
+ * Verifies tokens for ONE (binding, issuer, audience) triple. A bare string audience is an app id. Cheap to
37
+ * construct; the cache is shared.
38
+ */
35
39
  export class TokenVerifier {
36
40
  private readonly inner: JwksVerifier
37
41
 
38
42
  constructor(
39
- binding: IamRpc,
43
+ binding: IamBinding,
40
44
  issuer: string,
41
- private readonly audience: string,
45
+ private readonly audience: string | TokenAudienceProfile,
42
46
  now: () => number = () => Math.floor(Date.now() / 1000),
43
47
  ) {
44
48
  this.inner = new JwksVerifier({ getJwks: () => binding.getJwks(), issuer, now, cache: cacheFor(binding) })
45
49
  }
46
50
 
47
- verify(token: string): Promise<VerifyResult> {
48
- return this.inner.verify(token, this.audience)
51
+ verify(token: string, audience: string | TokenAudienceProfile = this.audience): Promise<VerifyResult> {
52
+ return this.inner.verify(token, audience)
49
53
  }
50
54
  }
package/src/middleware.ts DELETED
@@ -1,29 +0,0 @@
1
- /**
2
- * The shared middleware contract consumed by `@fabrika/app`. The auth package owns the shape so an
3
- * app's own auth middleware — the one that calls `iam.authenticate(request)` and stores the resolved
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-0022).
6
- *
7
- * A middleware runs in order; it may:
8
- * - mutate `ctx` (e.g. set `ctx.auth`),
9
- * - short-circuit by returning a Response WITHOUT calling `next()`,
10
- * - or wrap `next()` — await it, then append headers to the returned Response.
11
- */
12
-
13
- import type { AuthContext } from './types'
14
-
15
- /**
16
- * A request-pipeline middleware over a caller-owned context `Ctx`. Runs in registration order. Return
17
- * a Response without awaiting `next()` to short-circuit; await `next()` and return its (possibly
18
- * header-augmented) Response to continue.
19
- */
20
- export type Middleware<Ctx> = (request: Request, ctx: Ctx, next: () => Promise<Response>) => Promise<Response>
21
-
22
- /**
23
- * The minimal shape an auth middleware needs of the pipeline context: a writable `auth` slot it sets to
24
- * the resolved `AuthContext` (or leaves null/undefined for an unresolved/anonymous caller). The
25
- * caller's real context may carry far more — this is only what the auth middlewares touch.
26
- */
27
- export interface AuthCarrier {
28
- auth?: AuthContext | null
29
- }