@fabrika/auth 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/client.ts +193 -0
- package/src/errors.ts +60 -0
- package/src/iam.ts +309 -0
- package/src/index.ts +69 -0
- package/src/json.ts +31 -0
- package/src/middleware.ts +29 -0
- package/src/provision.ts +146 -0
- package/src/request.ts +48 -0
- package/src/rpc-http.ts +318 -0
- package/src/scope.ts +25 -0
- package/src/types.ts +161 -0
- package/src/verify.ts +108 -0
package/src/json.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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-0007).
|
|
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
|
+
}
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Deploy-time schema reconcile — authz-vocabulary as code.
|
|
2
|
+
//
|
|
3
|
+
// Each app OWNS its authz vocabulary (scope dimensions, action catalog, roles) and declares it
|
|
4
|
+
// in its own code as an `AppSchema`. At deploy time the app reconciles that declaration into
|
|
5
|
+
// IAM by PUTting it to the idempotent admin endpoint `PUT /admin/apps/:app/schema`, so the
|
|
6
|
+
// IAM Worker's DB always mirrors what the app actually checks at runtime. An app's FIRST schema
|
|
7
|
+
// reconcile is also how it REGISTERS itself in IAM's app registry.
|
|
8
|
+
//
|
|
9
|
+
// The same deploy step optionally projects the app's RETURN ORIGINS (ADR-0021) — see
|
|
10
|
+
// `returnOrigins` below. That is a SECOND call to a different endpoint, deliberately: the schema is
|
|
11
|
+
// vocabulary the application declares, a return origin is a fact the control plane knows, and the
|
|
12
|
+
// two must never travel in one body.
|
|
13
|
+
//
|
|
14
|
+
// This is a DEPLOY/OPERATOR helper, NOT a runtime one: it talks HTTP to the admin origin (not the
|
|
15
|
+
// `IAM` service binding) and authenticates with an IAM-issued `px_` ADMIN key as a bearer —
|
|
16
|
+
// reconcile is a privileged admin operation. Call it from a deploy step / provisioning script,
|
|
17
|
+
// never from request handling.
|
|
18
|
+
//
|
|
19
|
+
// Idempotent: the endpoint upserts scopes/actions/origin='app' roles and deletes app-origin rows
|
|
20
|
+
// absent from the body; origin='custom' policies are never touched. Re-running is safe, and so is
|
|
21
|
+
// re-sending the same return-origin set.
|
|
22
|
+
|
|
23
|
+
import type { AppSchema } from '@fabrika/auth-core'
|
|
24
|
+
|
|
25
|
+
export interface ReconcileSchemaOptions {
|
|
26
|
+
/** The IAM Worker's admin origin, e.g. `https://iam.example.com` (trailing slash ok). */
|
|
27
|
+
url: string
|
|
28
|
+
/** The app id; its FIRST reconcile registers it in IAM's app registry. */
|
|
29
|
+
app: string
|
|
30
|
+
/** The app's declared vocabulary. */
|
|
31
|
+
schema: AppSchema
|
|
32
|
+
/**
|
|
33
|
+
* Every origin IAM may hand this app a session at (ADR-0021), as known by the CONTROL PLANE.
|
|
34
|
+
*
|
|
35
|
+
* Omit to leave the registry alone; a supplied set REPLACES it, so it must be the complete set for
|
|
36
|
+
* the app and not just the environment being deployed. It is NEVER read off the `AppSchema`: an app
|
|
37
|
+
* that could name its own return origin could name any origin, which is the thing the registry
|
|
38
|
+
* exists to prevent. An empty array is a caller error — clearing the registry is its own admin
|
|
39
|
+
* operation, not an accident of having nothing to send.
|
|
40
|
+
*/
|
|
41
|
+
returnOrigins?: readonly string[]
|
|
42
|
+
/**
|
|
43
|
+
* An IAM-issued `px_` ADMIN key, sent as `Authorization: Bearer`. Effectively required: IAM's CSRF
|
|
44
|
+
* guard exempts BEARER-ONLY callers, and nothing else — a credential-less server-side call sends
|
|
45
|
+
* neither `Origin` nor `Referer` and is refused whatever the admin-origin registry holds. The
|
|
46
|
+
* exemption rests on one fact, that a browser never attaches `Authorization` by itself; no such
|
|
47
|
+
* fact covers a credential-less request, and `resolveAdmin` would read it as a global admin.
|
|
48
|
+
*/
|
|
49
|
+
adminKey?: string
|
|
50
|
+
/** Cancels the in-flight request when schema reconciliation belongs to a deploy lifecycle. */
|
|
51
|
+
signal?: AbortSignal
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Thrown when the admin endpoint rejects the reconcile; `status` is the HTTP status. */
|
|
55
|
+
export class ReconcileSchemaError extends Error {
|
|
56
|
+
constructor(message: string, readonly status: number) {
|
|
57
|
+
super(message)
|
|
58
|
+
this.name = 'ReconcileSchemaError'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pull a human-readable message out of an admin failure body. REST answers `{ error: "…" }`;
|
|
64
|
+
* `/admin/rpc` answers `{ error: { type, message } }`. Null when neither shape is present.
|
|
65
|
+
*/
|
|
66
|
+
function errorMessage(value: unknown): string | null {
|
|
67
|
+
if (typeof value !== 'object' || value === null || !('error' in value)) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const message = value.error
|
|
71
|
+
if (typeof message === 'string') {
|
|
72
|
+
return message
|
|
73
|
+
}
|
|
74
|
+
if (typeof message === 'object' && message !== null && 'message' in message && typeof message.message === 'string') {
|
|
75
|
+
return message.message
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reconcile one app's declared `AppSchema` into IAM (idempotent), then — when `returnOrigins` is
|
|
82
|
+
* supplied — project that set into IAM's return-origin registry. Resolves on success; throws
|
|
83
|
+
* `ReconcileSchemaError` on a non-2xx response, or `Error` on a half-set service token.
|
|
84
|
+
*/
|
|
85
|
+
export async function reconcileSchema(options: ReconcileSchemaOptions): Promise<void> {
|
|
86
|
+
const { url, app, schema, returnOrigins, adminKey, signal } = options
|
|
87
|
+
|
|
88
|
+
if (returnOrigins !== undefined && returnOrigins.length === 0) {
|
|
89
|
+
throw new Error('reconcileSchema: returnOrigins must not be empty — omit it to leave the registry untouched')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
93
|
+
if (adminKey !== undefined) {
|
|
94
|
+
headers['authorization'] = `Bearer ${adminKey}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Trim a trailing slash so `${base}${path}` never doubles up.
|
|
98
|
+
const base = url.replace(/\/+$/, '')
|
|
99
|
+
const schemaPath = `/admin/apps/${encodeURIComponent(app)}/schema`
|
|
100
|
+
const response = await fetch(`${base}${schemaPath}`, {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers,
|
|
103
|
+
body: JSON.stringify(schema),
|
|
104
|
+
signal,
|
|
105
|
+
})
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new ReconcileSchemaError(`PUT /admin/apps/${app}/schema failed (${response.status}): ${await detail(response)}`, response.status)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (returnOrigins === undefined) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Second call, second endpoint, and it runs SECOND on purpose: `apps.setReturnOrigins` answers 404
|
|
115
|
+
// for an app IAM has never heard of, and the schema PUT above is what registers it.
|
|
116
|
+
const registration = await fetch(`${base}/admin/rpc`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers,
|
|
119
|
+
body: JSON.stringify({ method: 'apps.setReturnOrigins', input: { app, origins: [...returnOrigins] } }),
|
|
120
|
+
signal,
|
|
121
|
+
})
|
|
122
|
+
const failure = registration.ok ? await rpcError(registration) : await detail(registration)
|
|
123
|
+
if (failure !== null) {
|
|
124
|
+
throw new ReconcileSchemaError(
|
|
125
|
+
`apps.setReturnOrigins for ${app} failed (${registration.status}): ${failure}`,
|
|
126
|
+
registration.status,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The admin API's own message for a failed response, falling back to the status text. */
|
|
132
|
+
async function detail(response: Response): Promise<string> {
|
|
133
|
+
const payload: unknown = await response.json().catch(() => null)
|
|
134
|
+
return errorMessage(payload) ?? response.statusText
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A single `/admin/rpc` call maps its error onto the HTTP status, so a 2xx is already success. Read
|
|
139
|
+
* the envelope anyway: a body carrying `error` at 200 means the call was answered in a shape this
|
|
140
|
+
* caller did not ask for (a batch, say), and treating it as success would report a registration that
|
|
141
|
+
* never happened. Null when the envelope is clean.
|
|
142
|
+
*/
|
|
143
|
+
async function rpcError(response: Response): Promise<string | null> {
|
|
144
|
+
const payload: unknown = await response.json().catch(() => null)
|
|
145
|
+
return errorMessage(payload)
|
|
146
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Reading the fabrika-native credential off the incoming Request, to forward to the IAM service's
|
|
2
|
+
// management RPCs (issueKey / issueJwt / revokeKey / listPrincipals). Precedence: the token the PROXY
|
|
3
|
+
// injected (the one it already verified), then an `Authorization: Bearer` (a machine `px_` key or a
|
|
4
|
+
// passthrough JWT). None of this is a security boundary —
|
|
5
|
+
// IAM re-resolves and re-authorizes the caller server-side from this credential.
|
|
6
|
+
|
|
7
|
+
import { PROXY_TOKEN_HEADER } from '@fabrika/auth-core'
|
|
8
|
+
|
|
9
|
+
/** Cloudflare's per-request ray id; we use it as the correlation request id. */
|
|
10
|
+
const RAY_HEADER = 'cf-ray'
|
|
11
|
+
|
|
12
|
+
export interface ForwardedCredentials {
|
|
13
|
+
/** The native credential (a `px_` key / passthrough JWT / `px_token`), or null when absent. */
|
|
14
|
+
credential: string | null
|
|
15
|
+
/** cf-ray, or a fresh UUID when absent (e.g. local dev). */
|
|
16
|
+
requestId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The correlation id for this request: cf-ray when the platform supplies one, else a fresh UUID. */
|
|
20
|
+
export function readRequestId(req: Request): string {
|
|
21
|
+
return req.headers.get(RAY_HEADER) ?? crypto.randomUUID()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pull the native credential and a correlation id off the request. `credential` may be null. */
|
|
25
|
+
export function readCredentials(req: Request): ForwardedCredentials {
|
|
26
|
+
return {
|
|
27
|
+
credential: nonEmpty(req.headers.get(PROXY_TOKEN_HEADER))
|
|
28
|
+
?? readBearer(req.headers.get('Authorization')),
|
|
29
|
+
requestId: readRequestId(req),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nonEmpty(value: string | null): string | null {
|
|
34
|
+
if (value === null) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const trimmed = value.trim()
|
|
38
|
+
return trimmed === '' ? null : trimmed
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
|
|
42
|
+
function readBearer(header: string | null): string | null {
|
|
43
|
+
if (header === null) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
|
47
|
+
return match ? (match[1]?.trim() ?? null) : null
|
|
48
|
+
}
|
package/src/rpc-http.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `IamRpc` over HTTP — the transport an app needs when it has no Cloudflare service binding.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS NOT: it is not a second IAM. `IamRpc` (@fabrika/auth-core) is unchanged and this class
|
|
5
|
+
* holds no policy — it POSTs the method's input as JSON, reads the result back, and hands it to the
|
|
6
|
+
* same callers (`Iam`, `IamClient`) the binding serves. Everything downstream of the
|
|
7
|
+
* contract — local token verification, `can()`/`scopedTo()`, delegation checks inside the IAM
|
|
8
|
+
* service — is byte-for-byte the same code on both transports. The ONLY difference is how the
|
|
9
|
+
* call travels.
|
|
10
|
+
*
|
|
11
|
+
* THE WIRE IS `@fabrika/iam`'s `src/rpc-http.ts`, and that file is the contract:
|
|
12
|
+
*
|
|
13
|
+
* POST {origin}/rpc/<method> Authorization: Bearer <key> body = the method's input
|
|
14
|
+
* → 200 with the method's result (INCLUDING a decided `{ ok: false, reason }`)
|
|
15
|
+
* → 204 for `audit` (it returns void over the binding, so there is nothing to encode)
|
|
16
|
+
* → 401 bad/absent key · 404 no such method OR the surface is disabled (an unset key 404s) ·
|
|
17
|
+
* 400 undecodable body · 405 not a POST
|
|
18
|
+
*
|
|
19
|
+
* A DECIDED NEGATIVE IS A 200, and that distinction is the whole point: `{ ok: false, reason }` means
|
|
20
|
+
* "IAM answered: no", while a non-200 means "IAM could not be consulted". Only the second is an
|
|
21
|
+
* incident, and only the second throws here.
|
|
22
|
+
*
|
|
23
|
+
* ── Why a transport failure THROWS ────────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* Over a service binding an unreachable IAM is not a case a caller has to model: the binding either
|
|
26
|
+
* resolves or the whole invocation fails. Over HTTP it is a real state, and mapping it onto the
|
|
27
|
+
* contract's `reason` union would be a lie — `invalid_session` says "we checked, and no", which is not
|
|
28
|
+
* what happened. So it raises `IamTransportError`, callers see a 5xx, and nothing is ever admitted on
|
|
29
|
+
* the strength of an answer that never arrived. This is the same split `@fabrika/proxy`'s
|
|
30
|
+
* `HttpIamGateway` makes with `IamUnavailableError`.
|
|
31
|
+
*
|
|
32
|
+
* ── One instance per process, deliberately ────────────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* The SDK caches the fetched JWKS and the tokens minted from `px_` share credentials in `WeakMap`s
|
|
35
|
+
* keyed by THE BINDING OBJECT. A fresh `HttpIamRpc` per request would therefore miss both caches every
|
|
36
|
+
* time and re-fetch the JWKS on every request. Build one for the process's lifetime and put it in the
|
|
37
|
+
* env, the way a binding lives for the isolate's lifetime.
|
|
38
|
+
*
|
|
39
|
+
* The key is a TRANSPORT credential, never a caller identity: it says "this request came from inside
|
|
40
|
+
* the deployment", exactly as a binding's unreachability does. Per-caller authorization still happens
|
|
41
|
+
* inside IAM, from the credential carried in the method's own input. It is never logged.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type {
|
|
45
|
+
AuditInput,
|
|
46
|
+
IamRpc,
|
|
47
|
+
IssueJwtInput,
|
|
48
|
+
IssueJwtResult,
|
|
49
|
+
IssueKeyInput,
|
|
50
|
+
IssueKeyResult,
|
|
51
|
+
Jwks,
|
|
52
|
+
ListPrincipalsInput,
|
|
53
|
+
ListPrincipalsResult,
|
|
54
|
+
MintFromKeyInput,
|
|
55
|
+
MintFromKeyResult,
|
|
56
|
+
MintTokenInput,
|
|
57
|
+
MintTokenResult,
|
|
58
|
+
PrincipalListItem,
|
|
59
|
+
PrincipalType,
|
|
60
|
+
PublicJwk,
|
|
61
|
+
RevokeKeyInput,
|
|
62
|
+
RevokeKeyResult,
|
|
63
|
+
} from '@fabrika/auth-core'
|
|
64
|
+
import { booleanField, numberField, prop, stringField } from './json'
|
|
65
|
+
|
|
66
|
+
/** Default per-call timeout. A hung IAM must fail the request, not hold a connection open forever. */
|
|
67
|
+
const DEFAULT_TIMEOUT_MS = 5_000
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Just the call shape this client uses. Narrower than `typeof fetch` on purpose: the global carries
|
|
71
|
+
* runtime-specific extras (Bun's `preconnect`) that a test double has no business implementing.
|
|
72
|
+
*/
|
|
73
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
|
|
74
|
+
|
|
75
|
+
export interface HttpIamRpcOptions {
|
|
76
|
+
/** The IAM service origin, e.g. `http://iam:3000` on a private network (trailing slash tolerated). */
|
|
77
|
+
origin: string
|
|
78
|
+
/** The shared secret gating `/rpc/*` (`FABRIKA_IAM_RPC_KEY` on the IAM side). NEVER logged. */
|
|
79
|
+
key: string
|
|
80
|
+
/** Per-call timeout in milliseconds. */
|
|
81
|
+
timeoutMs?: number
|
|
82
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
83
|
+
fetch?: FetchLike
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* IAM could not be consulted — network failure, timeout, a non-2xx status, or a body that does not
|
|
88
|
+
* match the contract. Distinct from a decided denial, which is a normal result.
|
|
89
|
+
*
|
|
90
|
+
* Carries NO cause and no response body: a `fetch` error stringifies the request URL, and a body can
|
|
91
|
+
* quote a credential that was on the way in.
|
|
92
|
+
*/
|
|
93
|
+
export class IamTransportError extends Error {
|
|
94
|
+
constructor(readonly method: string) {
|
|
95
|
+
super(`iam unavailable: ${method}`)
|
|
96
|
+
this.name = 'IamTransportError'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class HttpIamRpc implements IamRpc {
|
|
101
|
+
private readonly origin: string
|
|
102
|
+
private readonly key: string
|
|
103
|
+
private readonly timeoutMs: number
|
|
104
|
+
private readonly fetchImpl: FetchLike
|
|
105
|
+
|
|
106
|
+
constructor(options: HttpIamRpcOptions) {
|
|
107
|
+
this.origin = options.origin.replace(/\/+$/, '')
|
|
108
|
+
this.key = options.key
|
|
109
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
110
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
|
|
114
|
+
const body = await this.call('mintToken', input)
|
|
115
|
+
const failure = readFailure(body)
|
|
116
|
+
if (failure !== null) {
|
|
117
|
+
return { ok: false, reason: oneOf(failure, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
|
|
118
|
+
}
|
|
119
|
+
return readMintSuccess(body, 'mintToken')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
|
|
123
|
+
const body = await this.call('mintFromKey', input)
|
|
124
|
+
const failure = readFailure(body)
|
|
125
|
+
if (failure !== null) {
|
|
126
|
+
return { ok: false, reason: oneOf(failure, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
|
|
127
|
+
}
|
|
128
|
+
return readMintSuccess(body, 'mintFromKey')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async issueKey(input: IssueKeyInput): Promise<IssueKeyResult> {
|
|
132
|
+
const body = await this.call('issueKey', input)
|
|
133
|
+
const failure = readFailure(body)
|
|
134
|
+
if (failure !== null) {
|
|
135
|
+
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
136
|
+
}
|
|
137
|
+
const token = stringField(body, 'token')
|
|
138
|
+
const id = stringField(body, 'id')
|
|
139
|
+
if (token === undefined || id === undefined) {
|
|
140
|
+
throw new IamTransportError('issueKey')
|
|
141
|
+
}
|
|
142
|
+
const principalId = stringField(body, 'principalId')
|
|
143
|
+
return { ok: true, token, id, ...(principalId === undefined ? {} : { principalId }) }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async issueJwt(input: IssueJwtInput): Promise<IssueJwtResult> {
|
|
147
|
+
const body = await this.call('issueJwt', input)
|
|
148
|
+
const failure = readFailure(body)
|
|
149
|
+
if (failure !== null) {
|
|
150
|
+
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
151
|
+
}
|
|
152
|
+
const token = stringField(body, 'token')
|
|
153
|
+
const expiresAt = numberField(body, 'expiresAt')
|
|
154
|
+
const id = stringField(body, 'id')
|
|
155
|
+
if (token === undefined || expiresAt === undefined || id === undefined) {
|
|
156
|
+
throw new IamTransportError('issueJwt')
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, token, expiresAt, id }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async revokeKey(input: RevokeKeyInput): Promise<RevokeKeyResult> {
|
|
162
|
+
const body = await this.call('revokeKey', input)
|
|
163
|
+
const failure = readFailure(body)
|
|
164
|
+
if (failure !== null) {
|
|
165
|
+
return { ok: false, reason: oneOf(failure, [...ISSUE_REASONS, 'not_found'], 'not_allowed') }
|
|
166
|
+
}
|
|
167
|
+
const revoked = booleanField(body, 'revoked')
|
|
168
|
+
if (revoked === undefined) {
|
|
169
|
+
throw new IamTransportError('revokeKey')
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, revoked }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async listPrincipals(input: ListPrincipalsInput): Promise<ListPrincipalsResult> {
|
|
175
|
+
const body = await this.call('listPrincipals', input)
|
|
176
|
+
const failure = readFailure(body)
|
|
177
|
+
if (failure !== null) {
|
|
178
|
+
return { ok: false, reason: oneOf(failure, ISSUE_REASONS, 'not_allowed') }
|
|
179
|
+
}
|
|
180
|
+
const raw = prop(body, 'principals')
|
|
181
|
+
if (!Array.isArray(raw)) {
|
|
182
|
+
throw new IamTransportError('listPrincipals')
|
|
183
|
+
}
|
|
184
|
+
const principals: PrincipalListItem[] = []
|
|
185
|
+
for (const item of raw) {
|
|
186
|
+
const id = stringField(item, 'id')
|
|
187
|
+
const label = stringField(item, 'label')
|
|
188
|
+
const type = stringField(item, 'type')
|
|
189
|
+
if (id === undefined || label === undefined || type === undefined) {
|
|
190
|
+
throw new IamTransportError('listPrincipals')
|
|
191
|
+
}
|
|
192
|
+
const email = prop(item, 'email')
|
|
193
|
+
principals.push({
|
|
194
|
+
id,
|
|
195
|
+
label,
|
|
196
|
+
// Anything but the literal 'service' is a user — the same narrowing the binding transport does.
|
|
197
|
+
type: principalType(type),
|
|
198
|
+
email: typeof email === 'string' ? email : null,
|
|
199
|
+
disabled: booleanField(item, 'disabled') ?? false,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, principals }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async getJwks(): Promise<Jwks> {
|
|
206
|
+
// `getJwks` takes no input, so an empty object body is the natural call (the server tolerates a
|
|
207
|
+
// wholly empty body too). It goes through `/rpc/*` like every other method rather than the public
|
|
208
|
+
// `/.well-known/jwks.json`, so ONE key and ONE surface serve the whole contract.
|
|
209
|
+
const body = await this.call('getJwks', {})
|
|
210
|
+
const rawKeys = prop(body, 'keys')
|
|
211
|
+
if (!Array.isArray(rawKeys)) {
|
|
212
|
+
throw new IamTransportError('getJwks')
|
|
213
|
+
}
|
|
214
|
+
const keys: PublicJwk[] = []
|
|
215
|
+
for (const raw of rawKeys) {
|
|
216
|
+
const kty = stringField(raw, 'kty')
|
|
217
|
+
if (kty === undefined) {
|
|
218
|
+
throw new IamTransportError('getJwks')
|
|
219
|
+
}
|
|
220
|
+
const key: PublicJwk = { kty }
|
|
221
|
+
for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
|
|
222
|
+
const value = stringField(raw, field)
|
|
223
|
+
if (value !== undefined) {
|
|
224
|
+
key[field] = value
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
keys.push(key)
|
|
228
|
+
}
|
|
229
|
+
return { keys }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async audit(event: AuditInput): Promise<void> {
|
|
233
|
+
// 204, no body — `audit` returns void over the binding, so there is nothing to decode. A transport
|
|
234
|
+
// failure still throws: an audit row that silently never happened is the one loss an audit trail
|
|
235
|
+
// cannot absorb.
|
|
236
|
+
await this.send('audit', event)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** POST one method and return its decoded JSON body. Throws `IamTransportError` on any transport fault. */
|
|
240
|
+
private async call(method: string, input: unknown): Promise<unknown> {
|
|
241
|
+
const response = await this.send(method, input)
|
|
242
|
+
const body: unknown = await response.json().catch(() => MALFORMED)
|
|
243
|
+
if (body === MALFORMED) {
|
|
244
|
+
throw new IamTransportError(method)
|
|
245
|
+
}
|
|
246
|
+
return body
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async send(method: string, input: unknown): Promise<Response> {
|
|
250
|
+
let response: Response
|
|
251
|
+
try {
|
|
252
|
+
response = await this.fetchImpl(`${this.origin}/rpc/${method}`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
'content-type': 'application/json',
|
|
256
|
+
accept: 'application/json',
|
|
257
|
+
authorization: `Bearer ${this.key}`,
|
|
258
|
+
},
|
|
259
|
+
body: JSON.stringify(input),
|
|
260
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
261
|
+
})
|
|
262
|
+
} catch {
|
|
263
|
+
// Swallow the cause deliberately: a fetch error stringifies the request URL and, on some
|
|
264
|
+
// runtimes, request detail. Only the method name is ever surfaced.
|
|
265
|
+
throw new IamTransportError(method)
|
|
266
|
+
}
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
throw new IamTransportError(method)
|
|
269
|
+
}
|
|
270
|
+
return response
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The five delegation-path failure reasons `issueKey` / `issueJwt` / `listPrincipals` share. */
|
|
275
|
+
const ISSUE_REASONS = ['missing_token', 'invalid_token', 'unknown_principal', 'disabled', 'not_allowed'] as const
|
|
276
|
+
|
|
277
|
+
/** Distinguishes "the body was not JSON" from a body that decoded to something the readers reject. */
|
|
278
|
+
const MALFORMED = Symbol('malformed')
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The `reason` of a decided negative, or null when the body is an `ok: true` result. A body that is
|
|
282
|
+
* neither (no `ok`, or `ok: false` with no reason) is a contract violation, not a denial — the caller
|
|
283
|
+
* then fails its own success decode and raises `IamTransportError`, which is the fail-closed answer.
|
|
284
|
+
*/
|
|
285
|
+
function readFailure(body: unknown): string | null {
|
|
286
|
+
if (prop(body, 'ok') !== false) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
return stringField(body, 'reason') ?? ''
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The `{ ok: true, token, expiresAt }` half both mint methods share. */
|
|
293
|
+
function readMintSuccess(body: unknown, method: string): { ok: true; token: string; expiresAt: number } {
|
|
294
|
+
const token = stringField(body, 'token')
|
|
295
|
+
const expiresAt = numberField(body, 'expiresAt')
|
|
296
|
+
if (token === undefined || token === '' || expiresAt === undefined) {
|
|
297
|
+
throw new IamTransportError(method)
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, token, expiresAt }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Narrow a wire `reason` to the declared union without a cast. An unrecognised reason degrades to the
|
|
304
|
+
* given fallback, which is ALWAYS a deny reason — an IAM that starts speaking a dialect this client
|
|
305
|
+
* does not know must not be able to talk it into an allow.
|
|
306
|
+
*/
|
|
307
|
+
function oneOf<T extends string>(value: string, allowed: readonly T[], fallback: T): T {
|
|
308
|
+
for (const candidate of allowed) {
|
|
309
|
+
if (candidate === value) {
|
|
310
|
+
return candidate
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return fallback
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function principalType(value: string): PrincipalType {
|
|
317
|
+
return value === 'service' ? 'service' : 'user'
|
|
318
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the three-state result from `scopedTo(action, dimension)` into a single value the
|
|
3
|
+
* app controls. This is the SINGLE place the empty-`IN ()` SQL trap is handled: `none`
|
|
4
|
+
* short-circuits without ever emitting `WHERE col IN ()`.
|
|
5
|
+
*
|
|
6
|
+
* - `null` → `all()` — unrestricted (admin / global grant): no filter
|
|
7
|
+
* - `[]` → `none()` — no access: empty result, DO NOT query
|
|
8
|
+
* - non-empty → `some(values)` — filter to these scope values (`WHERE col IN (...)`)
|
|
9
|
+
*
|
|
10
|
+
* The `values` are the opaque, app-owned scope values for that dimension (the app keys its
|
|
11
|
+
* rows by them). The app supplies what all/some/none mean for its own query; filtering must
|
|
12
|
+
* happen at the data layer, never by loading everything and filtering in memory.
|
|
13
|
+
*/
|
|
14
|
+
export function applyScope<T>(
|
|
15
|
+
scope: string[] | null,
|
|
16
|
+
branches: { all: () => T; some: (values: string[]) => T; none: () => T },
|
|
17
|
+
): T {
|
|
18
|
+
if (scope === null) {
|
|
19
|
+
return branches.all()
|
|
20
|
+
}
|
|
21
|
+
if (scope.length === 0) {
|
|
22
|
+
return branches.none()
|
|
23
|
+
}
|
|
24
|
+
return branches.some(scope)
|
|
25
|
+
}
|