@fabrika/auth 0.3.3 → 0.4.0
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 +3 -3
- package/src/app-schema.ts +238 -11
- package/src/client.ts +29 -14
- package/src/iam.ts +35 -18
- package/src/index.ts +18 -16
- package/src/membership.ts +110 -0
- package/src/rpc-http.ts +282 -44
- package/src/types.ts +22 -1
- package/src/verify.ts +14 -10
- package/src/middleware.ts +0 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
31
|
-
"@fabrika/jwks": "^0.
|
|
30
|
+
"@fabrika/auth-core": "^0.4.0",
|
|
31
|
+
"@fabrika/jwks": "^0.4.0",
|
|
32
32
|
"jose": "^5.9.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/app-schema.ts
CHANGED
|
@@ -1,25 +1,236 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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({
|
|
10
|
-
|
|
11
|
-
|
|
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 =
|
|
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,
|
|
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:
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
30
|
-
IAM?:
|
|
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
|
-
/**
|
|
34
|
-
|
|
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.
|
|
37
|
+
/** Options for `createIam`. `appId` falls back to `env.FABRIKA_IAM_APP_ID`. */
|
|
38
38
|
export interface CreateIamOptions {
|
|
39
|
-
/** The IAM 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 —
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
43
|
-
// Scope (the `{ type, value }` coordinate apps pass to `can()`), the AppSchema vocabulary types
|
|
44
|
-
// apps DECLARE in `fabrika.schema.ts` for `reconcileSchema()
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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 —
|
|
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 {
|
|
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<
|
|
24
|
+
const jwksCache = new WeakMap<IamBinding, JwksCache>()
|
|
24
25
|
|
|
25
|
-
function cacheFor(binding:
|
|
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
|
-
/**
|
|
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:
|
|
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,
|
|
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
|
-
}
|