@fabrika/auth 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/app-schema.ts +34 -0
- package/src/client.ts +6 -94
- package/src/errors.ts +1 -1
- package/src/iam.ts +9 -123
- package/src/index.ts +6 -16
- package/src/middleware.ts +1 -1
- package/src/provision.ts +13 -20
- package/src/request.ts +3 -4
- package/src/rpc-http.ts +73 -85
- package/src/types.ts +3 -87
- package/src/verify.ts +1 -1
- package/src/json.ts +0 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
"test": "bun test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"
|
|
30
|
-
"@fabrika/
|
|
29
|
+
"zod": "^3.25.76",
|
|
30
|
+
"@fabrika/auth-core": "^0.3.1",
|
|
31
|
+
"@fabrika/jwks": "^0.3.1",
|
|
31
32
|
"jose": "^5.9.0"
|
|
32
33
|
}
|
|
33
34
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type AppSchema, isActionAllowed } from '@fabrika/auth-core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/** Quote an untrusted fragment for an error message a run log may carry. */
|
|
5
|
+
const quote = (value: string): string => `\`${value.slice(0, 80)}\``
|
|
6
|
+
|
|
7
|
+
const scopeSchema = z.object({ type: z.string().min(1), label: z.string().min(1).optional() })
|
|
8
|
+
const actionSchema = z.object({ action: z.string().min(1), description: z.string().min(1).optional() })
|
|
9
|
+
const roleSchema = z.object({ name: z.string().min(1), description: z.string().min(1).optional(), permissions: z.array(z.string().min(1)) })
|
|
10
|
+
// `roles[key] = …` would reach the prototype setter, so `__proto__` is refused rather than assigned.
|
|
11
|
+
const roleKeySchema = z.string().min(1).refine((key) => key !== '__proto__')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An app's authz vocabulary, as an app declares it and IAM reconciles it. Every role permission must
|
|
15
|
+
* name an action the same body declares — IAM's own rule, checked wherever the vocabulary is read.
|
|
16
|
+
*/
|
|
17
|
+
export const appSchemaSchema = z.object({
|
|
18
|
+
scopes: z.array(scopeSchema),
|
|
19
|
+
actions: z.array(actionSchema),
|
|
20
|
+
roles: z.record(roleKeySchema, roleSchema),
|
|
21
|
+
}).superRefine((schema, ctx) => {
|
|
22
|
+
const catalog = schema.actions.map((action) => action.action)
|
|
23
|
+
for (const [key, role] of Object.entries(schema.roles)) {
|
|
24
|
+
for (const [index, pattern] of role.permissions.entries()) {
|
|
25
|
+
if (!isActionAllowed(pattern, catalog)) {
|
|
26
|
+
ctx.addIssue({
|
|
27
|
+
code: 'custom',
|
|
28
|
+
path: ['roles', key, 'permissions', index],
|
|
29
|
+
message: `role ${quote(key)} references unknown action: ${quote(pattern)}`,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}) satisfies z.ZodType<AppSchema, z.ZodTypeDef, unknown>
|
package/src/client.ts
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
import type { AccessTokenClaims, DomainEvent, IamRpc, PermissionEntry, Scope } from '@fabrika/auth-core'
|
|
2
2
|
import { permits, scopedValues } from '@fabrika/auth-core'
|
|
3
3
|
import { readCredentials } from './request'
|
|
4
|
-
import type {
|
|
5
|
-
AuthContext,
|
|
6
|
-
IssuedJwt,
|
|
7
|
-
IssuedKey,
|
|
8
|
-
IssueFailure,
|
|
9
|
-
IssueJwtRequest,
|
|
10
|
-
IssueKeyRequest,
|
|
11
|
-
ListPrincipalsFailure,
|
|
12
|
-
PrincipalIdentity,
|
|
13
|
-
PrincipalList,
|
|
14
|
-
RevokedKey,
|
|
15
|
-
RevokeFailure,
|
|
16
|
-
} from './types'
|
|
4
|
+
import type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
|
|
17
5
|
|
|
18
6
|
// ── AuthContext (real) ─────────────────────────────────────────────────────────
|
|
19
7
|
|
|
20
8
|
/**
|
|
21
9
|
* The real `AuthContext`. `can`/`scopedTo` are pure functions over the resolved permission array (no
|
|
22
10
|
* per-check round-trip); only `audit` calls the binding. Backs BOTH a principal-bound caller (cookie
|
|
23
|
-
* session / API key) and an ANONYMOUS one (
|
|
11
|
+
* session / API key) and an ANONYMOUS one (a standalone share link) — the latter has
|
|
24
12
|
* `principal = null`; `audit` then attributes to the credential label + token id, principal null.
|
|
25
13
|
*/
|
|
26
14
|
class RealAuthContext implements AuthContext {
|
|
@@ -32,9 +20,9 @@ class RealAuthContext implements AuthContext {
|
|
|
32
20
|
private readonly permissions: PermissionEntry[],
|
|
33
21
|
private readonly requestId: string,
|
|
34
22
|
readonly principal: PrincipalIdentity | null,
|
|
35
|
-
/** Audit actor label — the principal's label, or the credential
|
|
23
|
+
/** Audit actor label — the principal's label, or the credential label. */
|
|
36
24
|
private readonly auditLabel: string,
|
|
37
|
-
/** The credential
|
|
25
|
+
/** The credential id, for the audit linkage of an anonymous caller (null when bound). */
|
|
38
26
|
private readonly anonymousTokenId: string | null,
|
|
39
27
|
) {}
|
|
40
28
|
|
|
@@ -68,8 +56,8 @@ class RealAuthContext implements AuthContext {
|
|
|
68
56
|
|
|
69
57
|
/**
|
|
70
58
|
* Build an `AuthContext` from a verified access token's claims — the seam every authentication path
|
|
71
|
-
* uses (the proxy-injected token
|
|
72
|
-
*
|
|
59
|
+
* uses (the proxy-injected token). The SDK has verified the token LOCALLY; this wraps its claims in the
|
|
60
|
+
* same `permits`-backed context the RPC path returns,
|
|
73
61
|
* so `can()` / `scopedTo()` / `audit()` behave identically. A token with no `ptype` is anonymous
|
|
74
62
|
* (`principal: null`).
|
|
75
63
|
*/
|
|
@@ -109,85 +97,9 @@ export class IamClient {
|
|
|
109
97
|
}
|
|
110
98
|
return { ok: false, reason: result.reason, status: listPrincipalsStatus(result.reason) }
|
|
111
99
|
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Mint an opaque, stored, revocable `px_` credential (API key / share link). Forwards the
|
|
115
|
-
* REQUESTER's credentials as the issuer — the IAM Worker resolves the issuer server-side and
|
|
116
|
-
* enforces the delegation rule (you can only delegate what you can do). Returns the plaintext
|
|
117
|
-
* `px_` token ONCE; persist only the `id` (the handle for `revokeKey`). The transport — bearer
|
|
118
|
-
* header for a machine, URL-path token for a share link — is the app's choice. With `service`
|
|
119
|
-
* set it instead creates a NEW machine principal and binds the key to it (the folded service
|
|
120
|
-
* token); `principalId` is then the new principal's durable handle.
|
|
121
|
-
*/
|
|
122
|
-
async issueKey(req: Request, input: IssueKeyRequest): Promise<IssuedKey | IssueFailure> {
|
|
123
|
-
const { credential, requestId } = readCredentials(req)
|
|
124
|
-
const result = await this.binding.issueKey({
|
|
125
|
-
app: this.appId,
|
|
126
|
-
credential,
|
|
127
|
-
requestId,
|
|
128
|
-
service: input.service,
|
|
129
|
-
principalId: input.principalId,
|
|
130
|
-
permissions: input.permissions,
|
|
131
|
-
label: input.label,
|
|
132
|
-
expiresAt: input.expiresAt,
|
|
133
|
-
})
|
|
134
|
-
if (result.ok) {
|
|
135
|
-
return { ok: true, token: result.token, id: result.id, ...(result.principalId === undefined ? {} : { principalId: result.principalId }) }
|
|
136
|
-
}
|
|
137
|
-
return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Sign a stateless passthrough access token (audit-only, TTL-bounded, NOT revocable). Forwards the
|
|
142
|
-
* REQUESTER's credentials as the issuer (same delegation rule as `issueKey`). The holder carries the
|
|
143
|
-
* returned JWT directly as `Authorization: Bearer`; apps verify it locally with no propustka
|
|
144
|
-
* round-trip. There is nothing to revoke — it is TTL-only by design.
|
|
145
|
-
*/
|
|
146
|
-
async issueJwt(req: Request, input: IssueJwtRequest): Promise<IssuedJwt | IssueFailure> {
|
|
147
|
-
const { credential, requestId } = readCredentials(req)
|
|
148
|
-
const result = await this.binding.issueJwt({
|
|
149
|
-
app: this.appId,
|
|
150
|
-
credential,
|
|
151
|
-
requestId,
|
|
152
|
-
permissions: input.permissions,
|
|
153
|
-
label: input.label,
|
|
154
|
-
ttl: input.ttl,
|
|
155
|
-
})
|
|
156
|
-
if (result.ok) {
|
|
157
|
-
return { ok: true, token: result.token, expiresAt: result.expiresAt, id: result.id }
|
|
158
|
-
}
|
|
159
|
-
return { ok: false, reason: result.reason, status: issueFailureStatus(result.reason) }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Revoke an opaque `px_` credential (API key / share link) by its id. Forwards the CALLER's
|
|
164
|
-
* credentials as the authorizer — the IAM Worker resolves the caller server-side and enforces the
|
|
165
|
-
* rule (the original issuer, or anyone who could re-issue the grants, may revoke). Idempotent: a
|
|
166
|
-
* second revoke returns `{ ok: true, revoked: false }`. An unknown id → 404.
|
|
167
|
-
*/
|
|
168
|
-
async revokeKey(req: Request, id: string): Promise<RevokedKey | RevokeFailure> {
|
|
169
|
-
const { credential, requestId } = readCredentials(req)
|
|
170
|
-
const result = await this.binding.revokeKey({ app: this.appId, credential, requestId, id })
|
|
171
|
-
if (result.ok) {
|
|
172
|
-
return { ok: true, revoked: result.revoked }
|
|
173
|
-
}
|
|
174
|
-
return { ok: false, reason: result.reason, status: revokeFailureStatus(result.reason) }
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
179
|
-
function issueFailureStatus(reason: IssueFailure['reason']): 401 | 403 {
|
|
180
|
-
return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
|
|
181
100
|
}
|
|
182
101
|
|
|
183
102
|
/** missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
184
103
|
function listPrincipalsStatus(reason: ListPrincipalsFailure['reason']): 401 | 403 {
|
|
185
104
|
return reason === 'missing_token' || reason === 'invalid_token' ? 401 : 403
|
|
186
105
|
}
|
|
187
|
-
|
|
188
|
-
/** missing/invalid → 401; not_found → 404; unknown_principal/disabled/not_allowed → 403. */
|
|
189
|
-
function revokeFailureStatus(reason: RevokeFailure['reason']): 401 | 403 | 404 {
|
|
190
|
-
if (reason === 'missing_token' || reason === 'invalid_token') return 401
|
|
191
|
-
if (reason === 'not_found') return 404
|
|
192
|
-
return 403
|
|
193
|
-
}
|
package/src/errors.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* classes — so these shapes and the framework reader are the single contract. They live in
|
|
8
8
|
* `@fabrika/auth` because `requirePermission` consumes an `AuthContext` (a client type).
|
|
9
9
|
*
|
|
10
|
-
* There is deliberately no login-bounce error here: since ADR-
|
|
10
|
+
* There is deliberately no login-bounce error here: since ADR-0022 the proxy 302s an unauthenticated
|
|
11
11
|
* browser, so an app never has to produce a login URL of its own.
|
|
12
12
|
*/
|
|
13
13
|
|
package/src/iam.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `createIam` — the single request-time entry point apps use instead of hand-rolling auth.
|
|
3
3
|
*
|
|
4
|
-
* Since [ADR-
|
|
4
|
+
* Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
|
|
5
5
|
* thing that enforces: it matches the app's gates, resolves the credential, and injects a verified
|
|
6
6
|
* access token as `PROXY_TOKEN_HEADER`. What is left for an app is small and fixed:
|
|
7
7
|
* - `authenticate(request)` — read that header and verify it LOCALLY against IAM's published JWKS
|
|
8
8
|
* (signature, `iss`, `aud`, `exp`), then build an `AuthContext`. The header is never trusted blindly.
|
|
9
|
-
* - `
|
|
10
|
-
* through a `public`/`service` gate and the app redeems the capability itself; there is no proxy
|
|
11
|
-
* equivalent by design.
|
|
12
|
-
* - the MANAGEMENT surface (`listPrincipals` / `issueKey` / `issueJwt` / `revokeKey`).
|
|
9
|
+
* - `listPrincipals` — the app's people directory.
|
|
13
10
|
*
|
|
14
11
|
* There is NO gate evaluation here, no session→token exchange, and no cookie is ever written.
|
|
15
12
|
*
|
|
@@ -19,21 +16,10 @@
|
|
|
19
16
|
* service that owns identity.
|
|
20
17
|
*/
|
|
21
18
|
|
|
22
|
-
import {
|
|
19
|
+
import { type IamRpc, PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
|
|
23
20
|
import { buildAuthContext, IamClient } from './client'
|
|
24
21
|
import { readRequestId } from './request'
|
|
25
|
-
import type {
|
|
26
|
-
AuthContext,
|
|
27
|
-
IssuedJwt,
|
|
28
|
-
IssuedKey,
|
|
29
|
-
IssueFailure,
|
|
30
|
-
IssueJwtRequest,
|
|
31
|
-
IssueKeyRequest,
|
|
32
|
-
ListPrincipalsFailure,
|
|
33
|
-
PrincipalList,
|
|
34
|
-
RevokedKey,
|
|
35
|
-
RevokeFailure,
|
|
36
|
-
} from './types'
|
|
22
|
+
import type { AuthContext, ListPrincipalsFailure, PrincipalList } from './types'
|
|
37
23
|
import { TokenVerifier } from './verify'
|
|
38
24
|
|
|
39
25
|
// ── Public config surfaces ──────────────────────────────────────────────────────
|
|
@@ -68,9 +54,9 @@ export type AuthFailureReason =
|
|
|
68
54
|
| 'unavailable'
|
|
69
55
|
|
|
70
56
|
/**
|
|
71
|
-
* The outcome of `authenticate
|
|
72
|
-
*
|
|
73
|
-
*
|
|
57
|
+
* The outcome of `authenticate`. A DECIDED NEGATIVE (`invalid_token`, `unknown_principal`) is not the
|
|
58
|
+
* same as "could not consult IAM" (`unavailable`, 503) — the same three-state rule `@fabrika/proxy`
|
|
59
|
+
* implements. `authenticate` throws for neither case.
|
|
74
60
|
*/
|
|
75
61
|
export type AuthResult =
|
|
76
62
|
| { ok: true; context: AuthContext }
|
|
@@ -97,46 +83,6 @@ export function anonymousContext(): AuthContext {
|
|
|
97
83
|
}
|
|
98
84
|
}
|
|
99
85
|
|
|
100
|
-
// ── Small helpers ────────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/** Hex SHA-256 — the key-cache index, so no plaintext credential is ever held in memory as a map key. */
|
|
103
|
-
async function digest(value: string): Promise<string> {
|
|
104
|
-
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
|
|
105
|
-
let hex = ''
|
|
106
|
-
for (const byte of bytes) {
|
|
107
|
-
hex += byte.toString(16).padStart(2, '0')
|
|
108
|
-
}
|
|
109
|
-
return hex
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Per-binding cache of the access token minted from a `px_` share credential, keyed by the credential's
|
|
114
|
-
* SHA-256 digest and bounded — a share link redeemed on every request would otherwise pin an unbounded
|
|
115
|
-
* number of plaintext credentials for the process lifetime.
|
|
116
|
-
*/
|
|
117
|
-
const KEY_CACHE_MAX_ENTRIES = 256
|
|
118
|
-
const keyTokenCache = new WeakMap<IamRpc, Map<string, { token: string; expiresAt: number }>>()
|
|
119
|
-
|
|
120
|
-
function keyCacheFor(binding: IamRpc): Map<string, { token: string; expiresAt: number }> {
|
|
121
|
-
let cache = keyTokenCache.get(binding)
|
|
122
|
-
if (cache === undefined) {
|
|
123
|
-
cache = new Map()
|
|
124
|
-
keyTokenCache.set(binding, cache)
|
|
125
|
-
}
|
|
126
|
-
return cache
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Insert with a hard bound, evicting the oldest entry (Map preserves insertion order). */
|
|
130
|
-
function rememberKey(cache: Map<string, { token: string; expiresAt: number }>, key: string, entry: { token: string; expiresAt: number }): void {
|
|
131
|
-
if (!cache.has(key) && cache.size >= KEY_CACHE_MAX_ENTRIES) {
|
|
132
|
-
const oldest = cache.keys().next()
|
|
133
|
-
if (oldest.done !== true) {
|
|
134
|
-
cache.delete(oldest.value)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
cache.set(key, entry)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
86
|
// ── Iam ──────────────────────────────────────────────────────────────────────────
|
|
141
87
|
|
|
142
88
|
/** Internal construction config — `createIam` assembles this; apps never build it directly. */
|
|
@@ -149,8 +95,8 @@ interface IamConfig {
|
|
|
149
95
|
|
|
150
96
|
/**
|
|
151
97
|
* The request-time IAM surface. Build it with `createIam` (the only intended entry point — the
|
|
152
|
-
* constructor takes an internal config). Bundles
|
|
153
|
-
*
|
|
98
|
+
* constructor takes an internal config). Bundles token verification and `listPrincipals`, both bound
|
|
99
|
+
* to the env + app id resolved at construction.
|
|
154
100
|
*/
|
|
155
101
|
export class Iam {
|
|
156
102
|
private readonly management: IamClient
|
|
@@ -172,18 +118,6 @@ export class Iam {
|
|
|
172
118
|
return this.management.listPrincipals(req)
|
|
173
119
|
}
|
|
174
120
|
|
|
175
|
-
issueKey(req: Request, input: IssueKeyRequest): Promise<IssuedKey | IssueFailure> {
|
|
176
|
-
return this.management.issueKey(req, input)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
issueJwt(req: Request, input: IssueJwtRequest): Promise<IssuedJwt | IssueFailure> {
|
|
180
|
-
return this.management.issueJwt(req, input)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
revokeKey(req: Request, id: string): Promise<RevokedKey | RevokeFailure> {
|
|
184
|
-
return this.management.revokeKey(req, id)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
121
|
// ── Authentication ────────────────────────────────────────────────────────────
|
|
188
122
|
|
|
189
123
|
/**
|
|
@@ -199,54 +133,6 @@ export class Iam {
|
|
|
199
133
|
return this.contextFor(token, readRequestId(request))
|
|
200
134
|
}
|
|
201
135
|
|
|
202
|
-
/**
|
|
203
|
-
* Redeem an opaque `px_` share credential (or a passthrough JWT) into an `AuthContext` OFF the gate
|
|
204
|
-
* path — the seam a capability/share link uses, where the token rides a query param or a cookie
|
|
205
|
-
* rather than satisfying a gate. A `px_` key is exchanged once via `mintFromKey` and cached per
|
|
206
|
-
* binding; anything else is verified locally with no IAM call. Never throws — map a failure to a 404
|
|
207
|
-
* so the surface does not reveal whether the resource exists.
|
|
208
|
-
*/
|
|
209
|
-
async redeemKey(token: string): Promise<AuthResult> {
|
|
210
|
-
const requestId = crypto.randomUUID()
|
|
211
|
-
if (!token.startsWith(API_KEY_PREFIX)) {
|
|
212
|
-
// A passthrough JWT — verify locally, no IAM call.
|
|
213
|
-
return await this.contextFor(token, requestId)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const cache = keyCacheFor(this.binding)
|
|
217
|
-
const index = await digest(token)
|
|
218
|
-
const now = Math.floor(Date.now() / 1000)
|
|
219
|
-
const cached = cache.get(index)
|
|
220
|
-
if (cached !== undefined && cached.expiresAt - now > TOKEN_REFRESH_SKEW_SECONDS) {
|
|
221
|
-
const result = await this.contextFor(cached.token, requestId)
|
|
222
|
-
if (result.ok || result.reason === 'unavailable') {
|
|
223
|
-
// Unverifiable because IAM is down is NOT evidence against the cached token — say so and stop.
|
|
224
|
-
return result
|
|
225
|
-
}
|
|
226
|
-
// A cached token that no longer verifies (rotated key, revoked issuer) is dropped, not trusted.
|
|
227
|
-
cache.delete(index)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
let minted: MintFromKeyResult
|
|
231
|
-
try {
|
|
232
|
-
minted = await this.binding.mintFromKey({ app: this.appId, key: token, requestId })
|
|
233
|
-
} catch {
|
|
234
|
-
// A transport failure is "we could not consult IAM", never a decided negative. Never log the
|
|
235
|
-
// error object: it can carry the credential or a request URL.
|
|
236
|
-
return { ok: false, reason: 'unavailable', status: 503 }
|
|
237
|
-
}
|
|
238
|
-
if (!minted.ok) {
|
|
239
|
-
return minted.reason === 'invalid_key'
|
|
240
|
-
? { ok: false, reason: 'invalid_token', status: 401 }
|
|
241
|
-
: { ok: false, reason: 'unknown_principal', status: 403 }
|
|
242
|
-
}
|
|
243
|
-
const result = await this.contextFor(minted.token, requestId)
|
|
244
|
-
if (result.ok) {
|
|
245
|
-
rememberKey(cache, index, { token: minted.token, expiresAt: minted.expiresAt })
|
|
246
|
-
}
|
|
247
|
-
return result
|
|
248
|
-
}
|
|
249
|
-
|
|
250
136
|
// ── Internals ─────────────────────────────────────────────────────────────────
|
|
251
137
|
|
|
252
138
|
/** Verify one token and wrap its claims in the `permits`-backed `AuthContext`. */
|
package/src/index.ts
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// Depends ONLY on @fabrika/auth-core: the binding is typed as the `IamRpc` contract (so the SDK
|
|
4
4
|
// never imports the IAM service), and `can()`/`scopedTo()` reuse core's `permits`/`matchAction`.
|
|
5
5
|
//
|
|
6
|
-
// Enforcement lives in @fabrika/proxy (ADR-
|
|
6
|
+
// Enforcement lives in @fabrika/proxy (ADR-0022). Nothing exported here can evaluate a gate.
|
|
7
7
|
|
|
8
8
|
export { IamClient } from './client'
|
|
9
9
|
export { applyScope } from './scope'
|
|
10
10
|
// The typed HTTP errors + the per-resource authorization helper a per-path gate cannot express.
|
|
11
11
|
export { ForbiddenError, requirePermission, UnauthenticatedError } from './errors'
|
|
12
12
|
export type { HttpError } from './errors'
|
|
13
|
-
// Request-time surface: the single `createIam` entry point (verification +
|
|
14
|
-
//
|
|
13
|
+
// Request-time surface: the single `createIam` entry point (verification + `listPrincipals`) and the
|
|
14
|
+
// anonymous context an app sets on a request the proxy admitted
|
|
15
15
|
// through a `public` gate.
|
|
16
16
|
export { anonymousContext, createIam, Iam } from './iam'
|
|
17
17
|
export type { AuthFailure, AuthFailureReason, AuthResult, CreateIamOptions, IamEnv } from './iam'
|
|
@@ -30,20 +30,10 @@ export type { VerifyResult } from './verify'
|
|
|
30
30
|
// Deploy-time helper: reconcile an app's declared AppSchema (authz vocabulary) into IAM (an
|
|
31
31
|
// HTTP admin call, NOT over the service binding). Import from a deploy/provisioning step.
|
|
32
32
|
export { reconcileSchema, ReconcileSchemaError } from './provision'
|
|
33
|
+
// The one schema for an app's declared vocabulary; every reader of an `AppSchema` parses with it.
|
|
34
|
+
export { appSchemaSchema } from './app-schema'
|
|
33
35
|
export type { ReconcileSchemaOptions } from './provision'
|
|
34
|
-
export type {
|
|
35
|
-
AuthContext,
|
|
36
|
-
IssuedJwt,
|
|
37
|
-
IssuedKey,
|
|
38
|
-
IssueFailure,
|
|
39
|
-
IssueJwtRequest,
|
|
40
|
-
IssueKeyRequest,
|
|
41
|
-
ListPrincipalsFailure,
|
|
42
|
-
PrincipalIdentity,
|
|
43
|
-
PrincipalList,
|
|
44
|
-
RevokedKey,
|
|
45
|
-
RevokeFailure,
|
|
46
|
-
} from './types'
|
|
36
|
+
export type { AuthContext, ListPrincipalsFailure, PrincipalIdentity, PrincipalList } from './types'
|
|
47
37
|
|
|
48
38
|
// The header the proxy injects its verified token on. Re-exported so an app never spells it out.
|
|
49
39
|
export { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
|
package/src/middleware.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The shared middleware contract consumed by `@fabrika/app`. The auth package owns the shape so an
|
|
3
3
|
* app's own auth middleware — the one that calls `iam.authenticate(request)` and stores the resolved
|
|
4
4
|
* context — drops into the request pipeline without a duplicate compatibility interface. The SDK
|
|
5
|
-
* itself ships no middleware: enforcement is the proxy's job (ADR-
|
|
5
|
+
* itself ships no middleware: enforcement is the proxy's job (ADR-0022).
|
|
6
6
|
*
|
|
7
7
|
* A middleware runs in order; it may:
|
|
8
8
|
* - mutate `ctx` (e.g. set `ctx.auth`),
|
package/src/provision.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// IAM Worker's DB always mirrors what the app actually checks at runtime. An app's FIRST schema
|
|
7
7
|
// reconcile is also how it REGISTERS itself in IAM's app registry.
|
|
8
8
|
//
|
|
9
|
-
// The same deploy step optionally projects the app's RETURN ORIGINS (ADR-
|
|
9
|
+
// The same deploy step optionally projects the app's RETURN ORIGINS (ADR-0022) — see
|
|
10
10
|
// `returnOrigins` below. That is a SECOND call to a different endpoint, deliberately: the schema is
|
|
11
11
|
// vocabulary the application declares, a return origin is a fact the control plane knows, and the
|
|
12
12
|
// two must never travel in one body.
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
// re-sending the same return-origin set.
|
|
22
22
|
|
|
23
23
|
import type { AppSchema } from '@fabrika/auth-core'
|
|
24
|
+
import { z } from 'zod'
|
|
25
|
+
|
|
26
|
+
const adminErrorSchema = z.object({
|
|
27
|
+
error: z.union([z.string(), z.object({ message: z.string() }).transform(value => value.message)]),
|
|
28
|
+
})
|
|
24
29
|
|
|
25
30
|
export interface ReconcileSchemaOptions {
|
|
26
31
|
/** The IAM Worker's admin origin, e.g. `https://iam.example.com` (trailing slash ok). */
|
|
@@ -30,21 +35,18 @@ export interface ReconcileSchemaOptions {
|
|
|
30
35
|
/** The app's declared vocabulary. */
|
|
31
36
|
schema: AppSchema
|
|
32
37
|
/**
|
|
33
|
-
* Every origin IAM may hand this app a session at (ADR-
|
|
38
|
+
* Every origin IAM may hand this app a session at (ADR-0022), as known by the CONTROL PLANE.
|
|
34
39
|
*
|
|
35
40
|
* Omit to leave the registry alone; a supplied set REPLACES it, so it must be the complete set for
|
|
36
41
|
* the app and not just the environment being deployed. It is NEVER read off the `AppSchema`: an app
|
|
37
42
|
* that could name its own return origin could name any origin, which is the thing the registry
|
|
38
|
-
* exists to prevent. An empty array is a caller error
|
|
39
|
-
*
|
|
43
|
+
* exists to prevent. An empty array is a caller error: an app is never un-registered by having
|
|
44
|
+
* nothing to send.
|
|
40
45
|
*/
|
|
41
46
|
returnOrigins?: readonly string[]
|
|
42
47
|
/**
|
|
43
|
-
* An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's
|
|
44
|
-
*
|
|
45
|
-
* neither `Origin` nor `Referer` and is refused whatever the admin-origin registry holds. The
|
|
46
|
-
* exemption rests on one fact, that a browser never attaches `Authorization` by itself; no such
|
|
47
|
-
* fact covers a credential-less request, and `resolveAdmin` would read it as a global admin.
|
|
48
|
+
* An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's
|
|
49
|
+
* `/admin/*` accepts only a bearer and refuses a credential-less call in every environment.
|
|
48
50
|
*/
|
|
49
51
|
adminKey?: string
|
|
50
52
|
/** Cancels the in-flight request when schema reconciliation belongs to a deploy lifecycle. */
|
|
@@ -64,17 +66,8 @@ export class ReconcileSchemaError extends Error {
|
|
|
64
66
|
* `/admin/rpc` answers `{ error: { type, message } }`. Null when neither shape is present.
|
|
65
67
|
*/
|
|
66
68
|
function errorMessage(value: unknown): string | null {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
const message = value.error
|
|
71
|
-
if (typeof message === 'string') {
|
|
72
|
-
return message
|
|
73
|
-
}
|
|
74
|
-
if (typeof message === 'object' && message !== null && 'message' in message && typeof message.message === 'string') {
|
|
75
|
-
return message.message
|
|
76
|
-
}
|
|
77
|
-
return null
|
|
69
|
+
const result = adminErrorSchema.safeParse(value)
|
|
70
|
+
return result.success ? result.data.error : null
|
|
78
71
|
}
|
|
79
72
|
|
|
80
73
|
/**
|
package/src/request.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Reading the fabrika-native credential off the incoming Request, to forward to the IAM service's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// passthrough JWT). None of this is a security boundary —
|
|
2
|
+
// `listPrincipals` RPC. Precedence: the token the PROXY injected (the one it already verified), then
|
|
3
|
+
// an `Authorization: Bearer` (a machine `px_` key). None of this is a security boundary —
|
|
5
4
|
// IAM re-resolves and re-authorizes the caller server-side from this credential.
|
|
6
5
|
|
|
7
6
|
import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
|
|
@@ -10,7 +9,7 @@ import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
|
|
|
10
9
|
const RAY_HEADER = 'cf-ray'
|
|
11
10
|
|
|
12
11
|
export interface ForwardedCredentials {
|
|
13
|
-
/** The native credential (a `px_` key /
|
|
12
|
+
/** The native credential (a `px_` key / `px_token`), or null when absent. */
|
|
14
13
|
credential: string | null
|
|
15
14
|
/** cf-ray, or a fresh UUID when absent (e.g. local dev). */
|
|
16
15
|
requestId: string
|
package/src/rpc-http.ts
CHANGED
|
@@ -42,26 +42,53 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import type {
|
|
45
|
+
ApproveDeviceAuthorizationInput,
|
|
46
|
+
ApproveDeviceAuthorizationResult,
|
|
45
47
|
AuditInput,
|
|
48
|
+
DenyDeviceAuthorizationResult,
|
|
46
49
|
IamRpc,
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
InspectDeviceAuthorizationInput,
|
|
51
|
+
InspectDeviceAuthorizationResult,
|
|
49
52
|
IssueKeyInput,
|
|
50
53
|
IssueKeyResult,
|
|
51
54
|
Jwks,
|
|
55
|
+
ListOwnDeviceAuthorizationsInput,
|
|
56
|
+
ListOwnDeviceAuthorizationsResult,
|
|
52
57
|
ListPrincipalsInput,
|
|
53
58
|
ListPrincipalsResult,
|
|
54
59
|
MintFromKeyInput,
|
|
55
60
|
MintFromKeyResult,
|
|
56
61
|
MintTokenInput,
|
|
57
62
|
MintTokenResult,
|
|
58
|
-
PrincipalListItem,
|
|
59
|
-
PrincipalType,
|
|
60
|
-
PublicJwk,
|
|
61
63
|
RevokeKeyInput,
|
|
62
64
|
RevokeKeyResult,
|
|
63
65
|
} from '@fabrika/auth-core'
|
|
64
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
parseApproveDeviceAuthorizationResult,
|
|
68
|
+
parseDenyDeviceAuthorizationResult,
|
|
69
|
+
parseInspectDeviceAuthorizationResult,
|
|
70
|
+
parseListOwnDeviceAuthorizationsResult,
|
|
71
|
+
} from '@fabrika/auth-core'
|
|
72
|
+
import { parseJwks } from '@fabrika/jwks'
|
|
73
|
+
import { z } from 'zod'
|
|
74
|
+
|
|
75
|
+
const mintSuccessSchema = z.object({ token: z.string().min(1), expiresAt: z.number().finite() })
|
|
76
|
+
const issueKeySuccessSchema = z.object({
|
|
77
|
+
token: z.string(),
|
|
78
|
+
id: z.string(),
|
|
79
|
+
principalId: z.string().optional().catch(undefined),
|
|
80
|
+
}).transform(({ principalId, ...result }) => ({ ...result, ...(principalId === undefined ? {} : { principalId }) }))
|
|
81
|
+
const revokeSuccessSchema = z.object({ revoked: z.boolean() })
|
|
82
|
+
const principalsSuccessSchema = z.object({
|
|
83
|
+
principals: z.array(z.object({
|
|
84
|
+
id: z.string(),
|
|
85
|
+
label: z.string(),
|
|
86
|
+
type: z.string().transform((value): 'service' | 'user' => value === 'service' ? 'service' : 'user'),
|
|
87
|
+
email: z.string().nullable().catch(null),
|
|
88
|
+
disabled: z.boolean().catch(false),
|
|
89
|
+
})),
|
|
90
|
+
})
|
|
91
|
+
const failureSchema = z.object({ ok: z.literal(false), reason: z.string().catch('') })
|
|
65
92
|
|
|
66
93
|
/** Default per-call timeout. A hung IAM must fail the request, not hold a connection open forever. */
|
|
67
94
|
const DEFAULT_TIMEOUT_MS = 5_000
|
|
@@ -98,6 +125,26 @@ export class IamTransportError extends Error {
|
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
export class HttpIamRpc implements IamRpc {
|
|
128
|
+
async inspectDeviceAuthorization(input: InspectDeviceAuthorizationInput): Promise<InspectDeviceAuthorizationResult> {
|
|
129
|
+
const result = parseInspectDeviceAuthorizationResult(await this.call('inspectDeviceAuthorization', input))
|
|
130
|
+
if (!result) throw new IamTransportError('inspectDeviceAuthorization')
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
async approveDeviceAuthorization(input: ApproveDeviceAuthorizationInput): Promise<ApproveDeviceAuthorizationResult> {
|
|
134
|
+
const result = parseApproveDeviceAuthorizationResult(await this.call('approveDeviceAuthorization', input))
|
|
135
|
+
if (!result) throw new IamTransportError('approveDeviceAuthorization')
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
async denyDeviceAuthorization(input: InspectDeviceAuthorizationInput): Promise<DenyDeviceAuthorizationResult> {
|
|
139
|
+
const result = parseDenyDeviceAuthorizationResult(await this.call('denyDeviceAuthorization', input))
|
|
140
|
+
if (!result) throw new IamTransportError('denyDeviceAuthorization')
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
async listOwnDeviceAuthorizations(input: ListOwnDeviceAuthorizationsInput): Promise<ListOwnDeviceAuthorizationsResult> {
|
|
144
|
+
const result = parseListOwnDeviceAuthorizationsResult(await this.call('listOwnDeviceAuthorizations', input))
|
|
145
|
+
if (!result) throw new IamTransportError('listOwnDeviceAuthorizations')
|
|
146
|
+
return result
|
|
147
|
+
}
|
|
101
148
|
private readonly origin: string
|
|
102
149
|
private readonly key: string
|
|
103
150
|
private readonly timeoutMs: number
|
|
@@ -134,28 +181,11 @@ export class HttpIamRpc implements IamRpc {
|
|
|
134
181
|
if (failure !== null) {
|
|
135
182
|
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
136
183
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
if (token === undefined || id === undefined) {
|
|
184
|
+
const result = issueKeySuccessSchema.safeParse(body)
|
|
185
|
+
if (!result.success) {
|
|
140
186
|
throw new IamTransportError('issueKey')
|
|
141
187
|
}
|
|
142
|
-
|
|
143
|
-
return { ok: true, token, id, ...(principalId === undefined ? {} : { principalId }) }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async issueJwt(input: IssueJwtInput): Promise<IssueJwtResult> {
|
|
147
|
-
const body = await this.call('issueJwt', input)
|
|
148
|
-
const failure = readFailure(body)
|
|
149
|
-
if (failure !== null) {
|
|
150
|
-
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
151
|
-
}
|
|
152
|
-
const token = stringField(body, 'token')
|
|
153
|
-
const expiresAt = numberField(body, 'expiresAt')
|
|
154
|
-
const id = stringField(body, 'id')
|
|
155
|
-
if (token === undefined || expiresAt === undefined || id === undefined) {
|
|
156
|
-
throw new IamTransportError('issueJwt')
|
|
157
|
-
}
|
|
158
|
-
return { ok: true, token, expiresAt, id }
|
|
188
|
+
return { ok: true, ...result.data }
|
|
159
189
|
}
|
|
160
190
|
|
|
161
191
|
async revokeKey(input: RevokeKeyInput): Promise<RevokeKeyResult> {
|
|
@@ -164,11 +194,11 @@ export class HttpIamRpc implements IamRpc {
|
|
|
164
194
|
if (failure !== null) {
|
|
165
195
|
return { ok: false, reason: oneOf(failure, [...ISSUE_REASONS, 'not_found'], 'not_allowed') }
|
|
166
196
|
}
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
197
|
+
const result = revokeSuccessSchema.safeParse(body)
|
|
198
|
+
if (!result.success) {
|
|
169
199
|
throw new IamTransportError('revokeKey')
|
|
170
200
|
}
|
|
171
|
-
return { ok: true,
|
|
201
|
+
return { ok: true, ...result.data }
|
|
172
202
|
}
|
|
173
203
|
|
|
174
204
|
async listPrincipals(input: ListPrincipalsInput): Promise<ListPrincipalsResult> {
|
|
@@ -177,56 +207,22 @@ export class HttpIamRpc implements IamRpc {
|
|
|
177
207
|
if (failure !== null) {
|
|
178
208
|
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
179
209
|
}
|
|
180
|
-
const
|
|
181
|
-
if (!
|
|
210
|
+
const result = principalsSuccessSchema.safeParse(body)
|
|
211
|
+
if (!result.success) {
|
|
182
212
|
throw new IamTransportError('listPrincipals')
|
|
183
213
|
}
|
|
184
|
-
|
|
185
|
-
for (const item of raw) {
|
|
186
|
-
const id = stringField(item, 'id')
|
|
187
|
-
const label = stringField(item, 'label')
|
|
188
|
-
const type = stringField(item, 'type')
|
|
189
|
-
if (id === undefined || label === undefined || type === undefined) {
|
|
190
|
-
throw new IamTransportError('listPrincipals')
|
|
191
|
-
}
|
|
192
|
-
const email = prop(item, 'email')
|
|
193
|
-
principals.push({
|
|
194
|
-
id,
|
|
195
|
-
label,
|
|
196
|
-
// Anything but the literal 'service' is a user — the same narrowing the binding transport does.
|
|
197
|
-
type: principalType(type),
|
|
198
|
-
email: typeof email === 'string' ? email : null,
|
|
199
|
-
disabled: booleanField(item, 'disabled') ?? false,
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
return { ok: true, principals }
|
|
214
|
+
return { ok: true, ...result.data }
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
async getJwks(): Promise<Jwks> {
|
|
206
218
|
// `getJwks` takes no input, so an empty object body is the natural call (the server tolerates a
|
|
207
219
|
// wholly empty body too). It goes through `/rpc/*` like every other method rather than the public
|
|
208
220
|
// `/.well-known/jwks.json`, so ONE key and ONE surface serve the whole contract.
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
if (!Array.isArray(rawKeys)) {
|
|
221
|
+
const jwks = parseJwks(await this.call('getJwks', {}))
|
|
222
|
+
if (jwks === null) {
|
|
212
223
|
throw new IamTransportError('getJwks')
|
|
213
224
|
}
|
|
214
|
-
|
|
215
|
-
for (const raw of rawKeys) {
|
|
216
|
-
const kty = stringField(raw, 'kty')
|
|
217
|
-
if (kty === undefined) {
|
|
218
|
-
throw new IamTransportError('getJwks')
|
|
219
|
-
}
|
|
220
|
-
const key: PublicJwk = { kty }
|
|
221
|
-
for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
|
|
222
|
-
const value = stringField(raw, field)
|
|
223
|
-
if (value !== undefined) {
|
|
224
|
-
key[field] = value
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
keys.push(key)
|
|
228
|
-
}
|
|
229
|
-
return { keys }
|
|
225
|
+
return jwks
|
|
230
226
|
}
|
|
231
227
|
|
|
232
228
|
async audit(event: AuditInput): Promise<void> {
|
|
@@ -271,32 +267,28 @@ export class HttpIamRpc implements IamRpc {
|
|
|
271
267
|
}
|
|
272
268
|
}
|
|
273
269
|
|
|
274
|
-
/** The five delegation-path failure reasons `issueKey` / `
|
|
270
|
+
/** The five delegation-path failure reasons `issueKey` / `revokeKey` / `listPrincipals` share. */
|
|
275
271
|
const ISSUE_REASONS = ['missing_token', 'invalid_token', 'unknown_principal', 'disabled', 'not_allowed'] as const
|
|
276
272
|
|
|
277
273
|
/** Distinguishes "the body was not JSON" from a body that decoded to something the readers reject. */
|
|
278
274
|
const MALFORMED = Symbol('malformed')
|
|
279
275
|
|
|
280
276
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* then fails its own success decode and raises `IamTransportError`, which is the fail-closed answer.
|
|
277
|
+
* Only literal false selects denial; missing or malformed reasons use the operation's deny fallback.
|
|
278
|
+
* Success fields are decoded independently of `ok`, preserving the SDK's tolerant wire contract.
|
|
284
279
|
*/
|
|
285
280
|
function readFailure(body: unknown): string | null {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
return stringField(body, 'reason') ?? ''
|
|
281
|
+
const result = failureSchema.safeParse(body)
|
|
282
|
+
return result.success ? result.data.reason : null
|
|
290
283
|
}
|
|
291
284
|
|
|
292
285
|
/** The `{ ok: true, token, expiresAt }` half both mint methods share. */
|
|
293
286
|
function readMintSuccess(body: unknown, method: string): { ok: true; token: string; expiresAt: number } {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
if (token === undefined || token === '' || expiresAt === undefined) {
|
|
287
|
+
const result = mintSuccessSchema.safeParse(body)
|
|
288
|
+
if (!result.success) {
|
|
297
289
|
throw new IamTransportError(method)
|
|
298
290
|
}
|
|
299
|
-
return { ok: true,
|
|
291
|
+
return { ok: true, ...result.data }
|
|
300
292
|
}
|
|
301
293
|
|
|
302
294
|
/**
|
|
@@ -312,7 +304,3 @@ function oneOf<T extends string>(value: string, allowed: readonly T[], fallback:
|
|
|
312
304
|
}
|
|
313
305
|
return fallback
|
|
314
306
|
}
|
|
315
|
-
|
|
316
|
-
function principalType(value: string): PrincipalType {
|
|
317
|
-
return value === 'service' ? 'service' : 'user'
|
|
318
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DomainEvent,
|
|
1
|
+
import type { DomainEvent, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The resolved caller's identity — who Access says you are, as the IAM Worker recorded
|
|
@@ -19,7 +19,7 @@ export interface PrincipalIdentity {
|
|
|
19
19
|
//
|
|
20
20
|
// Every method returns a discriminated union whose members all carry an `ok` field,
|
|
21
21
|
// so app code branches once on `result.ok`. The ok:true members are the rich
|
|
22
|
-
// `AuthContext` / `
|
|
22
|
+
// `AuthContext` / `PrincipalList` surfaces; the ok:false members are
|
|
23
23
|
// the typed failures below, each carrying the HTTP status the app should return.
|
|
24
24
|
//
|
|
25
25
|
// The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
|
|
@@ -35,7 +35,7 @@ export interface AuthContext {
|
|
|
35
35
|
readonly ok: true
|
|
36
36
|
/**
|
|
37
37
|
* The resolved caller identity (id / type / label), or `null` for an ANONYMOUS credential — a
|
|
38
|
-
*
|
|
38
|
+
* standalone (no-principal) share link. For stamping domain rows and
|
|
39
39
|
* display — NOT a permission surface (authorization stays `can` / `scopedTo`, which work the same
|
|
40
40
|
* either way). Stamp `created_by` with `principal?.id ?? null`.
|
|
41
41
|
*/
|
|
@@ -61,38 +61,6 @@ export interface AuthContext {
|
|
|
61
61
|
audit(event: DomainEvent): Promise<void>
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/** Successful `issueKey` — the plaintext `px_` token returned ONCE; `id` is the durable revoke handle. */
|
|
65
|
-
export interface IssuedKey {
|
|
66
|
-
readonly ok: true
|
|
67
|
-
/** Plaintext `px_` token — show once, never persist. Carry as a bearer or a share-link path token. */
|
|
68
|
-
token: string
|
|
69
|
-
/** The credential id (safe to store/reference; pass to `revokeKey`). */
|
|
70
|
-
id: string
|
|
71
|
-
/**
|
|
72
|
-
* The principal the key is bound to — the freshly-created service principal (`service` mode) or the
|
|
73
|
-
* issuer's own id (self-bind); absent for a standalone (anonymous) share link.
|
|
74
|
-
*/
|
|
75
|
-
principalId?: string
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Successful `issueJwt` — the signed passthrough token returned ONCE (audit-only, not revocable). */
|
|
79
|
-
export interface IssuedJwt {
|
|
80
|
-
readonly ok: true
|
|
81
|
-
/** The signed JWT — carry it as `Authorization: Bearer`. Verified locally by the app's SDK. */
|
|
82
|
-
token: string
|
|
83
|
-
/** Expiry (unix seconds). */
|
|
84
|
-
expiresAt: number
|
|
85
|
-
/** The token's audit reference (there is no DB row to revoke). */
|
|
86
|
-
id: string
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Successful `revokeKey`. `revoked` is false when the credential was already revoked. */
|
|
90
|
-
export interface RevokedKey {
|
|
91
|
-
readonly ok: true
|
|
92
|
-
/** True when this call flipped the credential to revoked; false if it was already revoked (idempotent). */
|
|
93
|
-
revoked: boolean
|
|
94
|
-
}
|
|
95
|
-
|
|
96
64
|
/** Successful `listPrincipals` — the app's people directory (user principals). */
|
|
97
65
|
export interface PrincipalList {
|
|
98
66
|
readonly ok: true
|
|
@@ -101,61 +69,9 @@ export interface PrincipalList {
|
|
|
101
69
|
|
|
102
70
|
// ── Failures ─────────────────────────────────────────────────────────────────
|
|
103
71
|
|
|
104
|
-
/** `issueKey` / `issueJwt` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
105
|
-
export interface IssueFailure {
|
|
106
|
-
readonly ok: false
|
|
107
|
-
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
|
|
108
|
-
status: 401 | 403
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** `revokeKey` failure. missing/invalid → 401; unknown/disabled/not_allowed → 403; not_found → 404. */
|
|
112
|
-
export interface RevokeFailure {
|
|
113
|
-
readonly ok: false
|
|
114
|
-
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed' | 'not_found'
|
|
115
|
-
status: 401 | 403 | 404
|
|
116
|
-
}
|
|
117
|
-
|
|
118
72
|
/** `listPrincipals` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
119
73
|
export interface ListPrincipalsFailure {
|
|
120
74
|
readonly ok: false
|
|
121
75
|
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
|
|
122
76
|
status: 401 | 403
|
|
123
77
|
}
|
|
124
|
-
|
|
125
|
-
// ── Inputs ───────────────────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* App-supplied portion of `issueKey` — an opaque, stored, revocable `px_` credential (API key /
|
|
129
|
-
* share link). The SDK fills `app` and the issuer's forwarded credential (the caller's `px_token` /
|
|
130
|
-
* `px_` key + requestId) from the request, so app code can never self-assert the issuer.
|
|
131
|
-
*/
|
|
132
|
-
export interface IssueKeyRequest {
|
|
133
|
-
/**
|
|
134
|
-
* Create a NEW machine (service) principal and bind the key to it — the folded service-token
|
|
135
|
-
* path. The issuer must hold every `permissions` action on `scope`. When set, `principalId` /
|
|
136
|
-
* `permissions` (the bind / standalone modes) are ignored.
|
|
137
|
-
*/
|
|
138
|
-
service?: IssueKeyServiceSpec
|
|
139
|
-
/**
|
|
140
|
-
* Bind to a principal (the credential then carries that principal's LIVE perms; inline
|
|
141
|
-
* `permissions` downscope it). v1: only the issuer's OWN id. Omit for a standalone share link.
|
|
142
|
-
*/
|
|
143
|
-
principalId?: string
|
|
144
|
-
/** Inline grants — the frozen set (standalone share link) and/or a downscope restriction (bound). */
|
|
145
|
-
permissions?: KeyGrant[]
|
|
146
|
-
label?: string
|
|
147
|
-
/** Absolute credential expiry (unix seconds); omitted = no expiry. */
|
|
148
|
-
expiresAt?: number
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* App-supplied portion of `issueJwt` — a stateless passthrough access token (audit-only, TTL-bounded,
|
|
153
|
-
* NOT revocable). The SDK fills `app` and the issuer's forwarded credential from the request.
|
|
154
|
-
*/
|
|
155
|
-
export interface IssueJwtRequest {
|
|
156
|
-
/** Inline grants the passthrough token carries (frozen at issue). */
|
|
157
|
-
permissions: KeyGrant[]
|
|
158
|
-
label?: string
|
|
159
|
-
/** Requested lifetime (seconds); capped by the server. Defaults to the standard token TTL. */
|
|
160
|
-
ttl?: number
|
|
161
|
-
}
|
package/src/verify.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local access-token verification — the only thing this SDK still does with a credential.
|
|
3
3
|
*
|
|
4
|
-
* Since [ADR-
|
|
4
|
+
* Since [ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md) the PROXY is the only
|
|
5
5
|
* enforcement point: it matches the app's gates, resolves the credential, and injects the verified
|
|
6
6
|
* token as `PROXY_TOKEN_HEADER`. The app must NOT trust that header blindly, so every token is
|
|
7
7
|
* re-verified here against IAM's published JWKS — signature, `iss`, `aud`, `exp` — before it can
|
package/src/json.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny structural readers for untrusted JSON — here, an IAM HTTP response body. They let the decoders
|
|
3
|
-
* in `rpc-http.ts` narrow without `as` casts: read a field, check its runtime type, proceed.
|
|
4
|
-
*
|
|
5
|
-
* Mirrors `@fabrika/auth-core`'s internal `json.ts` (not exported from its entry point) and
|
|
6
|
-
* `@fabrika/proxy`'s copy. Three tiny functions duplicated three times is cheaper than widening a
|
|
7
|
-
* published package's public API to share them.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Read a property off an unknown value (undefined when absent / not an object). */
|
|
11
|
-
export function prop(value: unknown, key: string): unknown {
|
|
12
|
-
if (typeof value !== 'object' || value === null) {
|
|
13
|
-
return undefined
|
|
14
|
-
}
|
|
15
|
-
return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function stringField(value: unknown, key: string): string | undefined {
|
|
19
|
-
const v = prop(value, key)
|
|
20
|
-
return typeof v === 'string' ? v : undefined
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function numberField(value: unknown, key: string): number | undefined {
|
|
24
|
-
const v = prop(value, key)
|
|
25
|
-
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function booleanField(value: unknown, key: string): boolean | undefined {
|
|
29
|
-
const v = prop(value, key)
|
|
30
|
-
return typeof v === 'boolean' ? v : undefined
|
|
31
|
-
}
|