@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/types.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { DomainEvent, IssueKeyServiceSpec, KeyGrant, PrincipalListItem, PrincipalType, Scope } from '@fabrika/auth-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The resolved caller's identity — who Access says you are, as the IAM Worker recorded
|
|
5
|
+
* the principal. Exposed on `AuthContext` so apps can stamp domain rows (created_by,
|
|
6
|
+
* activity actor, assignee) and render "signed in as …" without a second lookup. It is
|
|
7
|
+
* NOT a permission surface — authorization is still `can()` / `scopedTo()`.
|
|
8
|
+
* - `id` — the IAM principal id (UUIDv7); stable, safe to store on domain rows.
|
|
9
|
+
* - `type` — 'user' (a human identity) or 'service' (a machine principal / API key).
|
|
10
|
+
* - `label` — human-readable: the user's email, or the service principal's name.
|
|
11
|
+
*/
|
|
12
|
+
export interface PrincipalIdentity {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly type: PrincipalType
|
|
15
|
+
readonly label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Result surfaces ──────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// Every method returns a discriminated union whose members all carry an `ok` field,
|
|
21
|
+
// so app code branches once on `result.ok`. The ok:true members are the rich
|
|
22
|
+
// `AuthContext` / `IssuedKey` / `IssuedJwt` surfaces; the ok:false members are
|
|
23
|
+
// the typed failures below, each carrying the HTTP status the app should return.
|
|
24
|
+
//
|
|
25
|
+
// The ok:true surfaces are modelled as *interfaces* (not concrete classes) so that both
|
|
26
|
+
// the real (`permits`-backed) and fake (allow-all-except-deny) implementations satisfy a
|
|
27
|
+
// single shared shape without any casts — see the design note in the package README of the
|
|
28
|
+
// spec. The documented surface (`ok` / `can` / `scopedTo` / `audit`) is the interface.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The authenticated, ok:true result. `can`/`scopedTo` are local & pure (no binding call);
|
|
32
|
+
* only `audit` reaches the IAM Worker.
|
|
33
|
+
*/
|
|
34
|
+
export interface AuthContext {
|
|
35
|
+
readonly ok: true
|
|
36
|
+
/**
|
|
37
|
+
* The resolved caller identity (id / type / label), or `null` for an ANONYMOUS credential — a
|
|
38
|
+
* passthrough JWT or a standalone (no-principal) share-link/API key. For stamping domain rows and
|
|
39
|
+
* display — NOT a permission surface (authorization stays `can` / `scopedTo`, which work the same
|
|
40
|
+
* either way). Stamp `created_by` with `principal?.id ?? null`.
|
|
41
|
+
*/
|
|
42
|
+
readonly principal: PrincipalIdentity | null
|
|
43
|
+
/**
|
|
44
|
+
* Point check: may this principal do `action` (optionally within `scope`, an opaque
|
|
45
|
+
* `{ type, value }` coordinate)? Scope-less → satisfied by GLOBAL permissions only; a
|
|
46
|
+
* scoped grant never widens into a scope-less allow.
|
|
47
|
+
*/
|
|
48
|
+
can(action: string, scope?: Scope): boolean
|
|
49
|
+
/**
|
|
50
|
+
* Scoping: which scope values may this principal perform `action` on within `dimension`
|
|
51
|
+
* (a scope type, e.g. `'organization'`)? Scopes are flat & independent — a list is always
|
|
52
|
+
* relative to one dimension.
|
|
53
|
+
* - `null` → unrestricted (holds the action globally — e.g. an admin)
|
|
54
|
+
* - `[]` → no access within this dimension at all
|
|
55
|
+
* - non-empty → exactly those (opaque, app-owned) scope values
|
|
56
|
+
* Consume via `applyScope` so the empty-`IN ()` trap is handled once. `dimension` is
|
|
57
|
+
* REQUIRED — multiple scope types may coexist, so the caller must say which one.
|
|
58
|
+
*/
|
|
59
|
+
scopedTo(action: string, dimension: string): string[] | null
|
|
60
|
+
/** Emit a domain audit event; app/principal/requestId are auto-attached. Fire-and-forget. */
|
|
61
|
+
audit(event: DomainEvent): Promise<void>
|
|
62
|
+
}
|
|
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
|
+
/** Successful `listPrincipals` — the app's people directory (user principals). */
|
|
97
|
+
export interface PrincipalList {
|
|
98
|
+
readonly ok: true
|
|
99
|
+
principals: PrincipalListItem[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Failures ─────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
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
|
+
/** `listPrincipals` failure. missing/invalid → 401; unknown_principal/disabled/not_allowed → 403. */
|
|
119
|
+
export interface ListPrincipalsFailure {
|
|
120
|
+
readonly ok: false
|
|
121
|
+
reason: 'missing_token' | 'invalid_token' | 'unknown_principal' | 'disabled' | 'not_allowed'
|
|
122
|
+
status: 401 | 403
|
|
123
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local access-token verification — the only thing this SDK still does with a credential.
|
|
3
|
+
*
|
|
4
|
+
* Since [ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md) the PROXY is the only
|
|
5
|
+
* enforcement point: it matches the app's gates, resolves the credential, and injects the verified
|
|
6
|
+
* token as `PROXY_TOKEN_HEADER`. The app must NOT trust that header blindly, so every token is
|
|
7
|
+
* re-verified here against IAM's published JWKS — signature, `iss`, `aud`, `exp` — before it can
|
|
8
|
+
* become an `AuthContext`.
|
|
9
|
+
*
|
|
10
|
+
* `verify` is THREE-STATE, exactly like `@fabrika/proxy`'s verifier: "this token is bad" (the
|
|
11
|
+
* caller's fault, 401) and "we could not check" (ours, 503) are different denials. Both deny; only
|
|
12
|
+
* the second is an incident. Collapsing them would let a JWKS outage look like a forged token.
|
|
13
|
+
*
|
|
14
|
+
* The JWKS is cached PER BINDING in a module-level WeakMap rather than per instance: `@fabrika/app`
|
|
15
|
+
* calls its `middleware(env)` factory on every request, so an SDK object built there is per-request
|
|
16
|
+
* while the binding lives for the isolate/process. An unknown `kid` (a key rotated in) forces exactly
|
|
17
|
+
* one refetch, and a stale verifier is never reused after a fetch failure — the previous key set may
|
|
18
|
+
* have been rotated out precisely because it was compromised.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type AccessTokenClaims, type IamRpc, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
|
|
22
|
+
import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
|
|
23
|
+
|
|
24
|
+
export type VerifyResult =
|
|
25
|
+
| { ok: true; claims: AccessTokenClaims }
|
|
26
|
+
/** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
|
|
27
|
+
| { ok: false; reason: 'invalid' }
|
|
28
|
+
/** The key set could not be obtained, so nothing could be checked. Deny, but say why. */
|
|
29
|
+
| { ok: false; reason: 'unavailable' }
|
|
30
|
+
|
|
31
|
+
/** How long a fetched JWKS is reused before a refetch (a rotation also forces one on demand). */
|
|
32
|
+
const JWKS_TTL_SECONDS = 600
|
|
33
|
+
|
|
34
|
+
/** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
|
|
35
|
+
const NO_KEY = Symbol('no-matching-key')
|
|
36
|
+
|
|
37
|
+
type JwksVerifier = ReturnType<typeof createLocalJWKSet>
|
|
38
|
+
|
|
39
|
+
/** Raised only by `jwks()`, so `verify` can tell an unfetchable key set from an untrustworthy token. */
|
|
40
|
+
class JwksUnavailableError extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super('the IAM key set could not be fetched')
|
|
43
|
+
this.name = 'JwksUnavailableError'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** JWKS verifier cached per binding, so it survives the per-request SDK objects built over it. */
|
|
48
|
+
const jwksCache = new WeakMap<IamRpc, { verifier: JwksVerifier; expiresAt: number }>()
|
|
49
|
+
|
|
50
|
+
/** Verifies tokens for ONE (binding, issuer, audience) triple. Cheap to construct; the cache is shared. */
|
|
51
|
+
export class TokenVerifier {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly binding: IamRpc,
|
|
54
|
+
private readonly issuer: string,
|
|
55
|
+
private readonly audience: string,
|
|
56
|
+
private readonly now: () => number = () => Math.floor(Date.now() / 1000),
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
async verify(token: string): Promise<VerifyResult> {
|
|
60
|
+
let payload: unknown
|
|
61
|
+
try {
|
|
62
|
+
payload = await this.verifyWith(token, false)
|
|
63
|
+
if (payload === NO_KEY) {
|
|
64
|
+
// Unknown kid — a key was rotated in. Refetch the JWKS once and retry.
|
|
65
|
+
payload = await this.verifyWith(token, true)
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof JwksUnavailableError) {
|
|
69
|
+
return { ok: false, reason: 'unavailable' }
|
|
70
|
+
}
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
if (payload === NO_KEY || payload === null) {
|
|
74
|
+
return { ok: false, reason: 'invalid' }
|
|
75
|
+
}
|
|
76
|
+
const claims = parseAccessClaims(payload)
|
|
77
|
+
return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
|
|
81
|
+
private async verifyWith(token: string, force: boolean): Promise<unknown> {
|
|
82
|
+
const jwks = await this.jwks(force)
|
|
83
|
+
try {
|
|
84
|
+
const { payload } = await jwtVerify(token, jwks, { issuer: this.issuer, audience: this.audience })
|
|
85
|
+
return payload
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async jwks(force: boolean): Promise<JwksVerifier> {
|
|
92
|
+
const now = this.now()
|
|
93
|
+
const cached = jwksCache.get(this.binding)
|
|
94
|
+
if (!force && cached !== undefined && cached.expiresAt > now) {
|
|
95
|
+
return cached.verifier
|
|
96
|
+
}
|
|
97
|
+
let keys: Jwks
|
|
98
|
+
try {
|
|
99
|
+
keys = await this.binding.getJwks()
|
|
100
|
+
} catch {
|
|
101
|
+
// Never re-wrap the cause: it can stringify a request URL or a transport credential.
|
|
102
|
+
throw new JwksUnavailableError()
|
|
103
|
+
}
|
|
104
|
+
const verifier = createLocalJWKSet(keys)
|
|
105
|
+
jwksCache.set(this.binding, { verifier, expiresAt: now + JWKS_TTL_SECONDS })
|
|
106
|
+
return verifier
|
|
107
|
+
}
|
|
108
|
+
}
|