@aooth/auth 0.1.8 → 0.1.9

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/dist/authz.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_clock = require("./clock-Bl-H3eqE.cjs");
3
3
  let node_crypto = require("node:crypto");
4
+ let jose = require("jose");
4
5
  //#region src/authz/authz-errors.ts
5
6
  /** A typed authorization-server failure. */
6
7
  var AuthorizeError = class extends Error {
@@ -52,11 +53,205 @@ var LoopbackClientPolicy = class {
52
53
  if (!isLoopbackRedirectUri(args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri must be a loopback address");
53
54
  return {
54
55
  redirectUri: args.redirectUri,
55
- tokenPolicy: structuredClone(this.tokenPolicy)
56
+ tokenPolicy: structuredClone(this.tokenPolicy),
57
+ ...args.scope !== void 0 && { scope: args.scope }
56
58
  };
57
59
  }
58
60
  };
59
61
  //#endregion
62
+ //#region src/authz/oidc-claims-resolver.ts
63
+ /**
64
+ * Resolves the OIDC profile claims to embed in an `id_token` for a given user +
65
+ * granted scope (AUTH-SERVER.md §4.9). The authorization server already controls
66
+ * the registered claims (`iss`/`aud`/`sub`/`iat`/`exp`/`nonce`) itself — this seam
67
+ * supplies the **profile** claims that depend on the consumer's user shape
68
+ * (`email`/`email_verified`/`name`/`picture`), which `@aooth/auth` cannot know.
69
+ *
70
+ * Pluggable like every other store/policy: the no-op default ({@link NoopOidcClaimsResolver},
71
+ * `sub`-only tokens) ships, and a consumer subclasses it to read its own user
72
+ * record. Bound in `@aooth/auth-moost` under `OIDC_CLAIMS_RESOLVER_TOKEN`.
73
+ *
74
+ * The map's keys are standard OIDC claim names; values MUST be JSON-safe. Honour
75
+ * the granted `scope` — only emit `email`/`email_verified` under `email`, and
76
+ * `name`/`picture`/etc. under `profile`, so a client receives only what it asked
77
+ * for (and was allowed).
78
+ */
79
+ var OidcClaimsResolver = class {};
80
+ /** Default resolver: emits no profile claims, so the `id_token` carries only `sub` + the registered claims. */
81
+ var NoopOidcClaimsResolver = class extends OidcClaimsResolver {
82
+ resolveClaims() {
83
+ return {};
84
+ }
85
+ };
86
+ /** `true` when `scope` (space-joined) grants `claim` — `"email"`/`"profile"` etc. */
87
+ function scopeGrants(scope, claim) {
88
+ if (!scope) return false;
89
+ return scope.split(/\s+/u).includes(claim);
90
+ }
91
+ //#endregion
92
+ //#region src/authz/registered-client-policy.ts
93
+ /**
94
+ * Tier-2 policy: a static registry of first-party clients. `resolveClient`
95
+ * authorizes the client + `redirect_uri` (against the registered allowlist) and
96
+ * resolves the granted scope + what the grant delivers (`id_token`/`access_token`,
97
+ * `aud = client_id`); `authenticateClient` authenticates the client at `/token`
98
+ * (`client_secret` for confidential clients; PKCE is the binding for public ones).
99
+ * An unregistered client or an unlisted redirect is rejected.
100
+ */
101
+ var RegisteredClientPolicy = class {
102
+ clients = /* @__PURE__ */ new Map();
103
+ constructor(opts) {
104
+ for (const c of opts.clients) this.clients.set(c.clientId, c);
105
+ }
106
+ resolveClient(args) {
107
+ const client = this.requireClient(args.clientId);
108
+ if (!this.redirectAllowed(client, args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri is not registered for this client");
109
+ const scope = this.grantScope(client, args.scope);
110
+ const idToken = client.idToken !== false && scopeGrants(scope, "openid");
111
+ return {
112
+ clientId: client.clientId,
113
+ redirectUri: args.redirectUri,
114
+ audience: client.clientId,
115
+ idToken,
116
+ accessToken: client.accessToken === true,
117
+ tokenPolicy: client.tokenPolicy ? structuredClone(client.tokenPolicy) : {},
118
+ ...scope !== void 0 && { scope }
119
+ };
120
+ }
121
+ authenticateClient(args) {
122
+ const client = this.requireClient(args.clientId);
123
+ if ((client.type ?? "public") !== "confidential") return;
124
+ if (!client.clientSecret || !args.clientSecret || !timingSafeEqualStr(args.clientSecret, client.clientSecret)) throw new AuthorizeError("invalid_client", "client authentication failed");
125
+ }
126
+ requireClient(clientId) {
127
+ const client = clientId ? this.clients.get(clientId) : void 0;
128
+ if (!client) throw new AuthorizeError("invalid_client", "unknown client");
129
+ return client;
130
+ }
131
+ redirectAllowed(client, uri) {
132
+ if (client.redirectUris?.includes(uri)) return true;
133
+ if (!client.redirectPrefixes?.length) return false;
134
+ let normalized;
135
+ try {
136
+ normalized = new URL(uri).href;
137
+ } catch {
138
+ return false;
139
+ }
140
+ return client.redirectPrefixes.some((p) => {
141
+ if (p.length === 0 || !normalized.startsWith(p)) return false;
142
+ if (p.endsWith("/")) return true;
143
+ const next = normalized[p.length];
144
+ return next === void 0 || next === "/" || next === "?" || next === "#";
145
+ });
146
+ }
147
+ grantScope(client, requested) {
148
+ if (!requested) return void 0;
149
+ const req = requested.split(/\s+/u).filter(Boolean);
150
+ const granted = client.scopes ? req.filter((s) => client.scopes.includes(s)) : req;
151
+ return granted.length > 0 ? granted.join(" ") : void 0;
152
+ }
153
+ };
154
+ /** Constant-time string compare that also fails closed on a length mismatch. */
155
+ function timingSafeEqualStr(a, b) {
156
+ const ab = Buffer.from(a, "utf8");
157
+ const bb = Buffer.from(b, "utf8");
158
+ if (ab.length !== bb.length) return false;
159
+ return (0, node_crypto.timingSafeEqual)(ab, bb);
160
+ }
161
+ //#endregion
162
+ //#region src/authz/composite-client-policy.ts
163
+ /**
164
+ * Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
165
+ * `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
166
+ * registered client, one without is a loopback CLI. The split is the safety
167
+ * boundary — a registered client is routed only to {@link RegisteredClientPolicy}
168
+ * (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
169
+ * redirect), and a no-`client_id` request is routed only to the loopback policy
170
+ * (so it cannot claim to be a registered client). Each sub-policy still owns its
171
+ * own redirect validation; this only picks which one runs.
172
+ */
173
+ var CompositeClientPolicy = class {
174
+ loopback;
175
+ registered;
176
+ constructor(opts) {
177
+ this.loopback = opts.loopback;
178
+ this.registered = opts.registered;
179
+ }
180
+ resolveClient(args) {
181
+ return args.clientId ? this.registered.resolveClient(args) : this.loopback.resolveClient(args);
182
+ }
183
+ authenticateClient(args) {
184
+ if (args.clientId) return this.registered.authenticateClient?.(args);
185
+ }
186
+ };
187
+ //#endregion
188
+ //#region src/authz/id-token-signer.ts
189
+ /**
190
+ * Signs OIDC `id_token`s and publishes the matching JWKS (AUTH-SERVER.md §4.9).
191
+ * Holds one asymmetric keypair; mints short-lived RS256/ES256 tokens with the
192
+ * issuer + audience + subject a relying `OidcProvider` validates, and exports the
193
+ * public half as a JWKS for `GET /auth/jwks`. Keys are imported lazily and cached
194
+ * (the Apple-client-secret pattern), so construction is cheap and synchronous.
195
+ *
196
+ * Never used for the access token (that stays in `AuthCredential`'s store) — the
197
+ * `id_token` is a separate, audience-bound identity assertion.
198
+ */
199
+ var IdTokenSigner = class {
200
+ /** The `iss` claim + discovery `issuer` (read by `/.well-known/openid-configuration`). */
201
+ issuer;
202
+ /** Signature algorithm — published in discovery's `id_token_signing_alg_values_supported`. */
203
+ alg;
204
+ /** Key id — the JWS header + JWKS entry `kid`. */
205
+ kid;
206
+ privateKeyPem;
207
+ publicKeyPem;
208
+ ttlSec;
209
+ clock;
210
+ privateKeyPromise;
211
+ jwksPromise;
212
+ constructor(opts) {
213
+ this.issuer = opts.issuer.replace(/\/$/u, "");
214
+ this.alg = opts.alg ?? "RS256";
215
+ this.kid = opts.kid;
216
+ this.privateKeyPem = opts.privateKey;
217
+ this.publicKeyPem = opts.publicKey;
218
+ this.ttlSec = opts.ttlSec ?? 300;
219
+ this.clock = opts.clock ?? require_clock.defaultClock;
220
+ }
221
+ /** Mint a signed `id_token` JWT. Iat/exp come from the injected clock. */
222
+ async sign(claims) {
223
+ const key = await this.importPrivateKey();
224
+ const nowSec = Math.floor(this.clock.now() / 1e3);
225
+ const ttl = claims.ttlSec ?? this.ttlSec;
226
+ const payload = { ...claims.extra };
227
+ if (claims.nonce !== void 0) payload.nonce = claims.nonce;
228
+ return new jose.SignJWT(payload).setProtectedHeader({
229
+ alg: this.alg,
230
+ kid: this.kid,
231
+ typ: "JWT"
232
+ }).setIssuer(this.issuer).setSubject(claims.sub).setAudience(claims.aud).setIssuedAt(nowSec).setExpirationTime(nowSec + ttl).sign(key);
233
+ }
234
+ /**
235
+ * The JWKS document served at `/auth/jwks` — the public key as a single
236
+ * `use: "sig"` entry tagged with the same `kid`/`alg` as the minted tokens, so
237
+ * a verifier selects it by `kid`. Computed once and cached.
238
+ */
239
+ async jwks() {
240
+ this.jwksPromise ??= (async () => {
241
+ const jwk = await (0, jose.exportJWK)(await (0, jose.importSPKI)(this.publicKeyPem, this.alg, { extractable: true }));
242
+ jwk.kid = this.kid;
243
+ jwk.alg = this.alg;
244
+ jwk.use = "sig";
245
+ return { keys: [jwk] };
246
+ })();
247
+ return this.jwksPromise;
248
+ }
249
+ importPrivateKey() {
250
+ this.privateKeyPromise ??= (0, jose.importPKCS8)(this.privateKeyPem, this.alg);
251
+ return this.privateKeyPromise;
252
+ }
253
+ };
254
+ //#endregion
60
255
  //#region src/authz/pending-authorization-store.ts
61
256
  /**
62
257
  * Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
@@ -91,7 +286,11 @@ var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
91
286
  expiresAt: now + this.ttlMs,
92
287
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
93
288
  ...rec.clientState !== void 0 && { clientState: rec.clientState },
94
- ...rec.scope !== void 0 && { scope: rec.scope }
289
+ ...rec.scope !== void 0 && { scope: rec.scope },
290
+ ...rec.nonce !== void 0 && { nonce: rec.nonce },
291
+ ...rec.idToken !== void 0 && { idToken: rec.idToken },
292
+ ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
293
+ ...rec.audience !== void 0 && { audience: rec.audience }
95
294
  };
96
295
  this.store.set(row.handle, structuredClone(row));
97
296
  return { handle: row.handle };
@@ -145,7 +344,12 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
145
344
  redirectUri: rec.redirectUri,
146
345
  tokenPolicy: structuredClone(rec.tokenPolicy),
147
346
  expiresAt: this.clock.now() + this.ttlMs,
148
- ...rec.clientId !== void 0 && { clientId: rec.clientId }
347
+ ...rec.clientId !== void 0 && { clientId: rec.clientId },
348
+ ...rec.scope !== void 0 && { scope: rec.scope },
349
+ ...rec.nonce !== void 0 && { nonce: rec.nonce },
350
+ ...rec.idToken !== void 0 && { idToken: rec.idToken },
351
+ ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
352
+ ...rec.audience !== void 0 && { audience: rec.audience }
149
353
  };
150
354
  this.store.set(code, structuredClone(row));
151
355
  return { code };
@@ -162,7 +366,13 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
162
366
  exports.AuthCodeStore = AuthCodeStore;
163
367
  exports.AuthCodeStoreMemory = AuthCodeStoreMemory;
164
368
  exports.AuthorizeError = AuthorizeError;
369
+ exports.CompositeClientPolicy = CompositeClientPolicy;
370
+ exports.IdTokenSigner = IdTokenSigner;
165
371
  exports.LoopbackClientPolicy = LoopbackClientPolicy;
372
+ exports.NoopOidcClaimsResolver = NoopOidcClaimsResolver;
373
+ exports.OidcClaimsResolver = OidcClaimsResolver;
166
374
  exports.PendingAuthorizationStore = PendingAuthorizationStore;
167
375
  exports.PendingAuthorizationStoreMemory = PendingAuthorizationStoreMemory;
376
+ exports.RegisteredClientPolicy = RegisteredClientPolicy;
168
377
  exports.isLoopbackRedirectUri = isLoopbackRedirectUri;
378
+ exports.scopeGrants = scopeGrants;
package/dist/authz.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { t as Clock } from "./clock-BjXa0LXb.cjs";
2
+ import { JWK } from "jose";
2
3
 
3
4
  //#region src/authz/token-policy.d.ts
4
5
  /**
@@ -34,7 +35,7 @@ interface TokenPolicy {
34
35
  * code. Messages are benign — they must not disclose whether a failure was an
35
36
  * unknown client vs. a bad redirect vs. an expired code.
36
37
  */
37
- type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
+ type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** A known client used a grant/response it is not allowed (Tier 2). */ | "unauthorized_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
39
  /** A typed authorization-server failure. */
39
40
  declare class AuthorizeError extends Error {
40
41
  readonly code: AuthorizeErrorCode;
@@ -53,6 +54,21 @@ interface ResolvedClient {
53
54
  redirectUri: string;
54
55
  /** What the grant mints (fixed here, recorded on the pending authorization). */
55
56
  tokenPolicy: TokenPolicy;
57
+ /**
58
+ * Tier 2 (OIDC): mint an `id_token` with `aud` = {@link audience}. Omitted ⇒
59
+ * no `id_token` (Tier-1 loopback).
60
+ */
61
+ idToken?: boolean;
62
+ /**
63
+ * Mint an access token. **Omitted ⇒ minted** (preserves Tier-1 loopback,
64
+ * where the CLI gets an access token); set `false` for a pure-OIDC sign-in
65
+ * client that should receive identity only.
66
+ */
67
+ accessToken?: boolean;
68
+ /** The `id_token` `aud` (the registered `client_id`). */
69
+ audience?: string;
70
+ /** Granted scope (space-joined) — `requested ∩ allowed`; drives the `id_token` profile claims. */
71
+ scope?: string;
56
72
  }
57
73
  /**
58
74
  * The pluggable trust boundary of the authorization server (AUTH-SERVER.md §4.5):
@@ -72,7 +88,18 @@ interface ClientRedirectPolicy {
72
88
  resolveClient(args: {
73
89
  clientId?: string;
74
90
  redirectUri: string;
91
+ scope?: string;
75
92
  }): ResolvedClient | Promise<ResolvedClient>;
93
+ /**
94
+ * Tier 2: authenticate the client at `POST /auth/token` — verify the
95
+ * `client_secret` of a confidential client (PKCE is the binding for a public
96
+ * one). THROW an {@link AuthorizeError} (`invalid_client`) on failure. Optional:
97
+ * a public-only policy (loopback) omits it.
98
+ */
99
+ authenticateClient?(args: {
100
+ clientId?: string;
101
+ clientSecret?: string;
102
+ }): void | Promise<void>;
76
103
  }
77
104
  /**
78
105
  * `true` when `uri` is a syntactically valid http(s) URL whose host is a
@@ -103,9 +130,205 @@ declare class LoopbackClientPolicy implements ClientRedirectPolicy {
103
130
  resolveClient(args: {
104
131
  clientId?: string;
105
132
  redirectUri: string;
133
+ scope?: string;
106
134
  }): ResolvedClient;
107
135
  }
108
136
  //#endregion
137
+ //#region src/authz/registered-client-policy.d.ts
138
+ /**
139
+ * One registered first-party client (AUTH-SERVER.md §4.5). The registry is the
140
+ * open-redirect / token-theft boundary, so a client's `redirect_uri` allowlist
141
+ * and what it may receive are declared HERE, never inferred from the request.
142
+ */
143
+ interface RegisteredClient {
144
+ /** Stable client identifier; the `id_token` `aud`. */
145
+ clientId: string;
146
+ /** Exact-match `redirect_uri` allowlist (the safe default). */
147
+ redirectUris?: string[];
148
+ /**
149
+ * Strict-prefix `redirect_uri` allowlist (an entry must be a non-empty prefix
150
+ * of the request). Looser than exact match — only use for a tightly-scoped
151
+ * path prefix on a trusted origin.
152
+ */
153
+ redirectPrefixes?: string[];
154
+ /** `"public"` (PKCE only) or `"confidential"` (PKCE + `client_secret`). Default `"public"`. */
155
+ type?: "public" | "confidential";
156
+ /** Shared secret for a confidential client (compared in constant time at `/token`). */
157
+ clientSecret?: string;
158
+ /** Mint an `id_token` (requires the granted scope to include `openid`). Default `true`. */
159
+ idToken?: boolean;
160
+ /** Also mint an access token for the main API. Default `false` — a pure sign-in client gets identity only. */
161
+ accessToken?: boolean;
162
+ /** Allowed scopes; the granted scope is `requested ∩ allowed`. Omit to allow any requested scope. */
163
+ scopes?: string[];
164
+ /** Token policy for the access token, when `accessToken` is set. */
165
+ tokenPolicy?: TokenPolicy;
166
+ }
167
+ interface RegisteredClientPolicyOptions {
168
+ clients: RegisteredClient[];
169
+ }
170
+ /**
171
+ * Tier-2 policy: a static registry of first-party clients. `resolveClient`
172
+ * authorizes the client + `redirect_uri` (against the registered allowlist) and
173
+ * resolves the granted scope + what the grant delivers (`id_token`/`access_token`,
174
+ * `aud = client_id`); `authenticateClient` authenticates the client at `/token`
175
+ * (`client_secret` for confidential clients; PKCE is the binding for public ones).
176
+ * An unregistered client or an unlisted redirect is rejected.
177
+ */
178
+ declare class RegisteredClientPolicy implements ClientRedirectPolicy {
179
+ private readonly clients;
180
+ constructor(opts: RegisteredClientPolicyOptions);
181
+ resolveClient(args: {
182
+ clientId?: string;
183
+ redirectUri: string;
184
+ scope?: string;
185
+ }): ResolvedClient;
186
+ authenticateClient(args: {
187
+ clientId?: string;
188
+ clientSecret?: string;
189
+ }): void;
190
+ private requireClient;
191
+ private redirectAllowed;
192
+ private grantScope;
193
+ }
194
+ //#endregion
195
+ //#region src/authz/composite-client-policy.d.ts
196
+ interface CompositeClientPolicyOptions {
197
+ /** Used when the request carries NO `client_id` (Tier-1 public/loopback CLI). */
198
+ loopback: ClientRedirectPolicy;
199
+ /** Used when the request carries a `client_id` (Tier-2 registered service). */
200
+ registered: ClientRedirectPolicy;
201
+ }
202
+ /**
203
+ * Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
204
+ * `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
205
+ * registered client, one without is a loopback CLI. The split is the safety
206
+ * boundary — a registered client is routed only to {@link RegisteredClientPolicy}
207
+ * (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
208
+ * redirect), and a no-`client_id` request is routed only to the loopback policy
209
+ * (so it cannot claim to be a registered client). Each sub-policy still owns its
210
+ * own redirect validation; this only picks which one runs.
211
+ */
212
+ declare class CompositeClientPolicy implements ClientRedirectPolicy {
213
+ private readonly loopback;
214
+ private readonly registered;
215
+ constructor(opts: CompositeClientPolicyOptions);
216
+ resolveClient(args: {
217
+ clientId?: string;
218
+ redirectUri: string;
219
+ scope?: string;
220
+ }): ResolvedClient | Promise<ResolvedClient>;
221
+ authenticateClient(args: {
222
+ clientId?: string;
223
+ clientSecret?: string;
224
+ }): void | Promise<void>;
225
+ }
226
+ //#endregion
227
+ //#region src/authz/id-token-signer.d.ts
228
+ /** Asymmetric signing algorithms an OIDC `id_token` may use (AUTH-SERVER.md §4.9). */
229
+ type IdTokenAlg = "RS256" | "ES256";
230
+ interface IdTokenSignerOptions {
231
+ /**
232
+ * The OIDC issuer — the `iss` claim AND the discovery `issuer`. A relying
233
+ * `OidcProvider` checks `id_token.iss` against this exactly, so it must match
234
+ * the value it was configured with (typically `{origin}/auth`).
235
+ */
236
+ issuer: string;
237
+ /** Signature algorithm. Default `"RS256"` (most universally accepted; `OidcProvider` accepts RS256/ES256). */
238
+ alg?: IdTokenAlg;
239
+ /** Key id — stamped in the JWS header AND the published JWKS entry, so a verifier matches the right key. */
240
+ kid: string;
241
+ /** PKCS8 PEM private key (lazily imported + cached). */
242
+ privateKey: string;
243
+ /** SPKI PEM public key — published in the JWKS so verifiers fetch it (lazily imported + cached). */
244
+ publicKey: string;
245
+ /** `id_token` lifetime in seconds. Default 300 (5 min — it is exchanged immediately). */
246
+ ttlSec?: number;
247
+ /** Injectable clock for deterministic `iat`/`exp` in tests. Defaults to wall-clock. */
248
+ clock?: Clock;
249
+ }
250
+ /** The claims the authorization server controls per-mint; profile claims ride `extra`. */
251
+ interface IdTokenClaims {
252
+ /** Subject — the stable user id (the token subject). */
253
+ sub: string;
254
+ /** Audience — the requesting `client_id`. Binds the token to one client (§6 audience binding). */
255
+ aud: string;
256
+ /** Echoed from the `/authorize` request when present; a relying party checks it to defeat replay. */
257
+ nonce?: string;
258
+ /** Per-mint lifetime override (seconds). */
259
+ ttlSec?: number;
260
+ /** Profile/standard claims merged into the payload (e.g. `email`, `email_verified`, `name`). MUST be JSON-safe. */
261
+ extra?: Record<string, unknown>;
262
+ }
263
+ /**
264
+ * Signs OIDC `id_token`s and publishes the matching JWKS (AUTH-SERVER.md §4.9).
265
+ * Holds one asymmetric keypair; mints short-lived RS256/ES256 tokens with the
266
+ * issuer + audience + subject a relying `OidcProvider` validates, and exports the
267
+ * public half as a JWKS for `GET /auth/jwks`. Keys are imported lazily and cached
268
+ * (the Apple-client-secret pattern), so construction is cheap and synchronous.
269
+ *
270
+ * Never used for the access token (that stays in `AuthCredential`'s store) — the
271
+ * `id_token` is a separate, audience-bound identity assertion.
272
+ */
273
+ declare class IdTokenSigner {
274
+ /** The `iss` claim + discovery `issuer` (read by `/.well-known/openid-configuration`). */
275
+ readonly issuer: string;
276
+ /** Signature algorithm — published in discovery's `id_token_signing_alg_values_supported`. */
277
+ readonly alg: IdTokenAlg;
278
+ /** Key id — the JWS header + JWKS entry `kid`. */
279
+ readonly kid: string;
280
+ private readonly privateKeyPem;
281
+ private readonly publicKeyPem;
282
+ private readonly ttlSec;
283
+ private readonly clock;
284
+ private privateKeyPromise?;
285
+ private jwksPromise?;
286
+ constructor(opts: IdTokenSignerOptions);
287
+ /** Mint a signed `id_token` JWT. Iat/exp come from the injected clock. */
288
+ sign(claims: IdTokenClaims): Promise<string>;
289
+ /**
290
+ * The JWKS document served at `/auth/jwks` — the public key as a single
291
+ * `use: "sig"` entry tagged with the same `kid`/`alg` as the minted tokens, so
292
+ * a verifier selects it by `kid`. Computed once and cached.
293
+ */
294
+ jwks(): Promise<{
295
+ keys: JWK[];
296
+ }>;
297
+ private importPrivateKey;
298
+ }
299
+ //#endregion
300
+ //#region src/authz/oidc-claims-resolver.d.ts
301
+ /**
302
+ * Resolves the OIDC profile claims to embed in an `id_token` for a given user +
303
+ * granted scope (AUTH-SERVER.md §4.9). The authorization server already controls
304
+ * the registered claims (`iss`/`aud`/`sub`/`iat`/`exp`/`nonce`) itself — this seam
305
+ * supplies the **profile** claims that depend on the consumer's user shape
306
+ * (`email`/`email_verified`/`name`/`picture`), which `@aooth/auth` cannot know.
307
+ *
308
+ * Pluggable like every other store/policy: the no-op default ({@link NoopOidcClaimsResolver},
309
+ * `sub`-only tokens) ships, and a consumer subclasses it to read its own user
310
+ * record. Bound in `@aooth/auth-moost` under `OIDC_CLAIMS_RESOLVER_TOKEN`.
311
+ *
312
+ * The map's keys are standard OIDC claim names; values MUST be JSON-safe. Honour
313
+ * the granted `scope` — only emit `email`/`email_verified` under `email`, and
314
+ * `name`/`picture`/etc. under `profile`, so a client receives only what it asked
315
+ * for (and was allowed).
316
+ */
317
+ declare abstract class OidcClaimsResolver {
318
+ /**
319
+ * @param userId the authenticated subject (the `id_token` `sub`).
320
+ * @param scope the granted scope, space-joined (e.g. `"openid email profile"`), or undefined.
321
+ * @returns a flat map of standard OIDC profile claims to merge into the `id_token`.
322
+ */
323
+ abstract resolveClaims(userId: string, scope: string | undefined): Record<string, unknown> | Promise<Record<string, unknown>>;
324
+ }
325
+ /** Default resolver: emits no profile claims, so the `id_token` carries only `sub` + the registered claims. */
326
+ declare class NoopOidcClaimsResolver extends OidcClaimsResolver {
327
+ resolveClaims(): Record<string, unknown>;
328
+ }
329
+ /** `true` when `scope` (space-joined) grants `claim` — `"email"`/`"profile"` etc. */
330
+ declare function scopeGrants(scope: string | undefined, claim: string): boolean;
331
+ //#endregion
109
332
  //#region src/authz/pending-authorization-store.d.ts
110
333
  /**
111
334
  * One in-flight authorization request, recorded at `GET /auth/authorize` and
@@ -125,8 +348,16 @@ interface PendingAuthorization {
125
348
  codeChallenge: string;
126
349
  /** The client's `state`, echoed back on the redirect so the client can correlate. */
127
350
  clientState?: string;
128
- /** Requested scope (space-joined), informational for Tier 1. */
351
+ /** Granted scope (space-joined) `requested allowed`; drives the `id_token` profile claims. */
129
352
  scope?: string;
353
+ /** OIDC `nonce` from the authorize request — echoed into the `id_token` (Tier 2). */
354
+ nonce?: string;
355
+ /** Mint an `id_token` at `/token` (Tier 2). */
356
+ idToken?: boolean;
357
+ /** Mint an access token at `/token`. Omitted ⇒ minted (Tier-1 loopback). */
358
+ accessToken?: boolean;
359
+ /** The `id_token` `aud` (the registered `client_id`). */
360
+ audience?: string;
130
361
  /** What the grant will mint (fixed at authorize time). */
131
362
  tokenPolicy: TokenPolicy;
132
363
  createdAt: number;
@@ -139,6 +370,10 @@ interface NewPendingAuthorization {
139
370
  codeChallenge: string;
140
371
  clientState?: string;
141
372
  scope?: string;
373
+ nonce?: string;
374
+ idToken?: boolean;
375
+ accessToken?: boolean;
376
+ audience?: string;
142
377
  tokenPolicy: TokenPolicy;
143
378
  }
144
379
  /**
@@ -198,6 +433,16 @@ interface AuthCode {
198
433
  redirectUri: string;
199
434
  /** Registered client id (Tier 2), absent for a public/loopback client. */
200
435
  clientId?: string;
436
+ /** Granted scope (space-joined) — drives the `id_token` profile claims. */
437
+ scope?: string;
438
+ /** OIDC `nonce` from the authorize request — echoed into the `id_token` (Tier 2). */
439
+ nonce?: string;
440
+ /** Mint an `id_token` at `/token` (Tier 2). */
441
+ idToken?: boolean;
442
+ /** Mint an access token at `/token`. Omitted ⇒ minted (Tier-1 loopback). */
443
+ accessToken?: boolean;
444
+ /** The `id_token` `aud` (the registered `client_id`). */
445
+ audience?: string;
201
446
  /** What `/token` mints when this code is redeemed. */
202
447
  tokenPolicy: TokenPolicy;
203
448
  expiresAt: number;
@@ -208,6 +453,11 @@ interface NewAuthCode {
208
453
  codeChallenge: string;
209
454
  redirectUri: string;
210
455
  clientId?: string;
456
+ scope?: string;
457
+ nonce?: string;
458
+ idToken?: boolean;
459
+ accessToken?: boolean;
460
+ audience?: string;
211
461
  tokenPolicy: TokenPolicy;
212
462
  }
213
463
  /**
@@ -249,4 +499,4 @@ declare class AuthCodeStoreMemory extends AuthCodeStore {
249
499
  consume(code: string): Promise<AuthCode | null>;
250
500
  }
251
501
  //#endregion
252
- export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri };
502
+ export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, CompositeClientPolicy, type CompositeClientPolicyOptions, type IdTokenAlg, type IdTokenClaims, IdTokenSigner, type IdTokenSignerOptions, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, NoopOidcClaimsResolver, OidcClaimsResolver, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type RegisteredClient, RegisteredClientPolicy, type RegisteredClientPolicyOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri, scopeGrants };
package/dist/authz.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { t as Clock } from "./clock-BjXa0LXb.mjs";
2
+ import { JWK } from "jose";
2
3
 
3
4
  //#region src/authz/token-policy.d.ts
4
5
  /**
@@ -34,7 +35,7 @@ interface TokenPolicy {
34
35
  * code. Messages are benign — they must not disclose whether a failure was an
35
36
  * unknown client vs. a bad redirect vs. an expired code.
36
37
  */
37
- type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
+ type AuthorizeErrorCode = /** Missing or malformed parameter at `/authorize` or `/token`. */"invalid_request" /** `redirect_uri` is not an allowed (loopback / registered) target. */ | "invalid_redirect" /** Code unknown / expired / already-redeemed, or PKCE verifier mismatch (`/token`). */ | "invalid_grant" /** Unknown or unauthenticated client (Tier 2). */ | "invalid_client" /** A known client used a grant/response it is not allowed (Tier 2). */ | "unauthorized_client" /** The user declined the authorization (consent). */ | "access_denied" /** An unexpected server-side failure. */ | "server_error";
38
39
  /** A typed authorization-server failure. */
39
40
  declare class AuthorizeError extends Error {
40
41
  readonly code: AuthorizeErrorCode;
@@ -53,6 +54,21 @@ interface ResolvedClient {
53
54
  redirectUri: string;
54
55
  /** What the grant mints (fixed here, recorded on the pending authorization). */
55
56
  tokenPolicy: TokenPolicy;
57
+ /**
58
+ * Tier 2 (OIDC): mint an `id_token` with `aud` = {@link audience}. Omitted ⇒
59
+ * no `id_token` (Tier-1 loopback).
60
+ */
61
+ idToken?: boolean;
62
+ /**
63
+ * Mint an access token. **Omitted ⇒ minted** (preserves Tier-1 loopback,
64
+ * where the CLI gets an access token); set `false` for a pure-OIDC sign-in
65
+ * client that should receive identity only.
66
+ */
67
+ accessToken?: boolean;
68
+ /** The `id_token` `aud` (the registered `client_id`). */
69
+ audience?: string;
70
+ /** Granted scope (space-joined) — `requested ∩ allowed`; drives the `id_token` profile claims. */
71
+ scope?: string;
56
72
  }
57
73
  /**
58
74
  * The pluggable trust boundary of the authorization server (AUTH-SERVER.md §4.5):
@@ -72,7 +88,18 @@ interface ClientRedirectPolicy {
72
88
  resolveClient(args: {
73
89
  clientId?: string;
74
90
  redirectUri: string;
91
+ scope?: string;
75
92
  }): ResolvedClient | Promise<ResolvedClient>;
93
+ /**
94
+ * Tier 2: authenticate the client at `POST /auth/token` — verify the
95
+ * `client_secret` of a confidential client (PKCE is the binding for a public
96
+ * one). THROW an {@link AuthorizeError} (`invalid_client`) on failure. Optional:
97
+ * a public-only policy (loopback) omits it.
98
+ */
99
+ authenticateClient?(args: {
100
+ clientId?: string;
101
+ clientSecret?: string;
102
+ }): void | Promise<void>;
76
103
  }
77
104
  /**
78
105
  * `true` when `uri` is a syntactically valid http(s) URL whose host is a
@@ -103,9 +130,205 @@ declare class LoopbackClientPolicy implements ClientRedirectPolicy {
103
130
  resolveClient(args: {
104
131
  clientId?: string;
105
132
  redirectUri: string;
133
+ scope?: string;
106
134
  }): ResolvedClient;
107
135
  }
108
136
  //#endregion
137
+ //#region src/authz/registered-client-policy.d.ts
138
+ /**
139
+ * One registered first-party client (AUTH-SERVER.md §4.5). The registry is the
140
+ * open-redirect / token-theft boundary, so a client's `redirect_uri` allowlist
141
+ * and what it may receive are declared HERE, never inferred from the request.
142
+ */
143
+ interface RegisteredClient {
144
+ /** Stable client identifier; the `id_token` `aud`. */
145
+ clientId: string;
146
+ /** Exact-match `redirect_uri` allowlist (the safe default). */
147
+ redirectUris?: string[];
148
+ /**
149
+ * Strict-prefix `redirect_uri` allowlist (an entry must be a non-empty prefix
150
+ * of the request). Looser than exact match — only use for a tightly-scoped
151
+ * path prefix on a trusted origin.
152
+ */
153
+ redirectPrefixes?: string[];
154
+ /** `"public"` (PKCE only) or `"confidential"` (PKCE + `client_secret`). Default `"public"`. */
155
+ type?: "public" | "confidential";
156
+ /** Shared secret for a confidential client (compared in constant time at `/token`). */
157
+ clientSecret?: string;
158
+ /** Mint an `id_token` (requires the granted scope to include `openid`). Default `true`. */
159
+ idToken?: boolean;
160
+ /** Also mint an access token for the main API. Default `false` — a pure sign-in client gets identity only. */
161
+ accessToken?: boolean;
162
+ /** Allowed scopes; the granted scope is `requested ∩ allowed`. Omit to allow any requested scope. */
163
+ scopes?: string[];
164
+ /** Token policy for the access token, when `accessToken` is set. */
165
+ tokenPolicy?: TokenPolicy;
166
+ }
167
+ interface RegisteredClientPolicyOptions {
168
+ clients: RegisteredClient[];
169
+ }
170
+ /**
171
+ * Tier-2 policy: a static registry of first-party clients. `resolveClient`
172
+ * authorizes the client + `redirect_uri` (against the registered allowlist) and
173
+ * resolves the granted scope + what the grant delivers (`id_token`/`access_token`,
174
+ * `aud = client_id`); `authenticateClient` authenticates the client at `/token`
175
+ * (`client_secret` for confidential clients; PKCE is the binding for public ones).
176
+ * An unregistered client or an unlisted redirect is rejected.
177
+ */
178
+ declare class RegisteredClientPolicy implements ClientRedirectPolicy {
179
+ private readonly clients;
180
+ constructor(opts: RegisteredClientPolicyOptions);
181
+ resolveClient(args: {
182
+ clientId?: string;
183
+ redirectUri: string;
184
+ scope?: string;
185
+ }): ResolvedClient;
186
+ authenticateClient(args: {
187
+ clientId?: string;
188
+ clientSecret?: string;
189
+ }): void;
190
+ private requireClient;
191
+ private redirectAllowed;
192
+ private grantScope;
193
+ }
194
+ //#endregion
195
+ //#region src/authz/composite-client-policy.d.ts
196
+ interface CompositeClientPolicyOptions {
197
+ /** Used when the request carries NO `client_id` (Tier-1 public/loopback CLI). */
198
+ loopback: ClientRedirectPolicy;
199
+ /** Used when the request carries a `client_id` (Tier-2 registered service). */
200
+ registered: ClientRedirectPolicy;
201
+ }
202
+ /**
203
+ * Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
204
+ * `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
205
+ * registered client, one without is a loopback CLI. The split is the safety
206
+ * boundary — a registered client is routed only to {@link RegisteredClientPolicy}
207
+ * (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
208
+ * redirect), and a no-`client_id` request is routed only to the loopback policy
209
+ * (so it cannot claim to be a registered client). Each sub-policy still owns its
210
+ * own redirect validation; this only picks which one runs.
211
+ */
212
+ declare class CompositeClientPolicy implements ClientRedirectPolicy {
213
+ private readonly loopback;
214
+ private readonly registered;
215
+ constructor(opts: CompositeClientPolicyOptions);
216
+ resolveClient(args: {
217
+ clientId?: string;
218
+ redirectUri: string;
219
+ scope?: string;
220
+ }): ResolvedClient | Promise<ResolvedClient>;
221
+ authenticateClient(args: {
222
+ clientId?: string;
223
+ clientSecret?: string;
224
+ }): void | Promise<void>;
225
+ }
226
+ //#endregion
227
+ //#region src/authz/id-token-signer.d.ts
228
+ /** Asymmetric signing algorithms an OIDC `id_token` may use (AUTH-SERVER.md §4.9). */
229
+ type IdTokenAlg = "RS256" | "ES256";
230
+ interface IdTokenSignerOptions {
231
+ /**
232
+ * The OIDC issuer — the `iss` claim AND the discovery `issuer`. A relying
233
+ * `OidcProvider` checks `id_token.iss` against this exactly, so it must match
234
+ * the value it was configured with (typically `{origin}/auth`).
235
+ */
236
+ issuer: string;
237
+ /** Signature algorithm. Default `"RS256"` (most universally accepted; `OidcProvider` accepts RS256/ES256). */
238
+ alg?: IdTokenAlg;
239
+ /** Key id — stamped in the JWS header AND the published JWKS entry, so a verifier matches the right key. */
240
+ kid: string;
241
+ /** PKCS8 PEM private key (lazily imported + cached). */
242
+ privateKey: string;
243
+ /** SPKI PEM public key — published in the JWKS so verifiers fetch it (lazily imported + cached). */
244
+ publicKey: string;
245
+ /** `id_token` lifetime in seconds. Default 300 (5 min — it is exchanged immediately). */
246
+ ttlSec?: number;
247
+ /** Injectable clock for deterministic `iat`/`exp` in tests. Defaults to wall-clock. */
248
+ clock?: Clock;
249
+ }
250
+ /** The claims the authorization server controls per-mint; profile claims ride `extra`. */
251
+ interface IdTokenClaims {
252
+ /** Subject — the stable user id (the token subject). */
253
+ sub: string;
254
+ /** Audience — the requesting `client_id`. Binds the token to one client (§6 audience binding). */
255
+ aud: string;
256
+ /** Echoed from the `/authorize` request when present; a relying party checks it to defeat replay. */
257
+ nonce?: string;
258
+ /** Per-mint lifetime override (seconds). */
259
+ ttlSec?: number;
260
+ /** Profile/standard claims merged into the payload (e.g. `email`, `email_verified`, `name`). MUST be JSON-safe. */
261
+ extra?: Record<string, unknown>;
262
+ }
263
+ /**
264
+ * Signs OIDC `id_token`s and publishes the matching JWKS (AUTH-SERVER.md §4.9).
265
+ * Holds one asymmetric keypair; mints short-lived RS256/ES256 tokens with the
266
+ * issuer + audience + subject a relying `OidcProvider` validates, and exports the
267
+ * public half as a JWKS for `GET /auth/jwks`. Keys are imported lazily and cached
268
+ * (the Apple-client-secret pattern), so construction is cheap and synchronous.
269
+ *
270
+ * Never used for the access token (that stays in `AuthCredential`'s store) — the
271
+ * `id_token` is a separate, audience-bound identity assertion.
272
+ */
273
+ declare class IdTokenSigner {
274
+ /** The `iss` claim + discovery `issuer` (read by `/.well-known/openid-configuration`). */
275
+ readonly issuer: string;
276
+ /** Signature algorithm — published in discovery's `id_token_signing_alg_values_supported`. */
277
+ readonly alg: IdTokenAlg;
278
+ /** Key id — the JWS header + JWKS entry `kid`. */
279
+ readonly kid: string;
280
+ private readonly privateKeyPem;
281
+ private readonly publicKeyPem;
282
+ private readonly ttlSec;
283
+ private readonly clock;
284
+ private privateKeyPromise?;
285
+ private jwksPromise?;
286
+ constructor(opts: IdTokenSignerOptions);
287
+ /** Mint a signed `id_token` JWT. Iat/exp come from the injected clock. */
288
+ sign(claims: IdTokenClaims): Promise<string>;
289
+ /**
290
+ * The JWKS document served at `/auth/jwks` — the public key as a single
291
+ * `use: "sig"` entry tagged with the same `kid`/`alg` as the minted tokens, so
292
+ * a verifier selects it by `kid`. Computed once and cached.
293
+ */
294
+ jwks(): Promise<{
295
+ keys: JWK[];
296
+ }>;
297
+ private importPrivateKey;
298
+ }
299
+ //#endregion
300
+ //#region src/authz/oidc-claims-resolver.d.ts
301
+ /**
302
+ * Resolves the OIDC profile claims to embed in an `id_token` for a given user +
303
+ * granted scope (AUTH-SERVER.md §4.9). The authorization server already controls
304
+ * the registered claims (`iss`/`aud`/`sub`/`iat`/`exp`/`nonce`) itself — this seam
305
+ * supplies the **profile** claims that depend on the consumer's user shape
306
+ * (`email`/`email_verified`/`name`/`picture`), which `@aooth/auth` cannot know.
307
+ *
308
+ * Pluggable like every other store/policy: the no-op default ({@link NoopOidcClaimsResolver},
309
+ * `sub`-only tokens) ships, and a consumer subclasses it to read its own user
310
+ * record. Bound in `@aooth/auth-moost` under `OIDC_CLAIMS_RESOLVER_TOKEN`.
311
+ *
312
+ * The map's keys are standard OIDC claim names; values MUST be JSON-safe. Honour
313
+ * the granted `scope` — only emit `email`/`email_verified` under `email`, and
314
+ * `name`/`picture`/etc. under `profile`, so a client receives only what it asked
315
+ * for (and was allowed).
316
+ */
317
+ declare abstract class OidcClaimsResolver {
318
+ /**
319
+ * @param userId the authenticated subject (the `id_token` `sub`).
320
+ * @param scope the granted scope, space-joined (e.g. `"openid email profile"`), or undefined.
321
+ * @returns a flat map of standard OIDC profile claims to merge into the `id_token`.
322
+ */
323
+ abstract resolveClaims(userId: string, scope: string | undefined): Record<string, unknown> | Promise<Record<string, unknown>>;
324
+ }
325
+ /** Default resolver: emits no profile claims, so the `id_token` carries only `sub` + the registered claims. */
326
+ declare class NoopOidcClaimsResolver extends OidcClaimsResolver {
327
+ resolveClaims(): Record<string, unknown>;
328
+ }
329
+ /** `true` when `scope` (space-joined) grants `claim` — `"email"`/`"profile"` etc. */
330
+ declare function scopeGrants(scope: string | undefined, claim: string): boolean;
331
+ //#endregion
109
332
  //#region src/authz/pending-authorization-store.d.ts
110
333
  /**
111
334
  * One in-flight authorization request, recorded at `GET /auth/authorize` and
@@ -125,8 +348,16 @@ interface PendingAuthorization {
125
348
  codeChallenge: string;
126
349
  /** The client's `state`, echoed back on the redirect so the client can correlate. */
127
350
  clientState?: string;
128
- /** Requested scope (space-joined), informational for Tier 1. */
351
+ /** Granted scope (space-joined) `requested allowed`; drives the `id_token` profile claims. */
129
352
  scope?: string;
353
+ /** OIDC `nonce` from the authorize request — echoed into the `id_token` (Tier 2). */
354
+ nonce?: string;
355
+ /** Mint an `id_token` at `/token` (Tier 2). */
356
+ idToken?: boolean;
357
+ /** Mint an access token at `/token`. Omitted ⇒ minted (Tier-1 loopback). */
358
+ accessToken?: boolean;
359
+ /** The `id_token` `aud` (the registered `client_id`). */
360
+ audience?: string;
130
361
  /** What the grant will mint (fixed at authorize time). */
131
362
  tokenPolicy: TokenPolicy;
132
363
  createdAt: number;
@@ -139,6 +370,10 @@ interface NewPendingAuthorization {
139
370
  codeChallenge: string;
140
371
  clientState?: string;
141
372
  scope?: string;
373
+ nonce?: string;
374
+ idToken?: boolean;
375
+ accessToken?: boolean;
376
+ audience?: string;
142
377
  tokenPolicy: TokenPolicy;
143
378
  }
144
379
  /**
@@ -198,6 +433,16 @@ interface AuthCode {
198
433
  redirectUri: string;
199
434
  /** Registered client id (Tier 2), absent for a public/loopback client. */
200
435
  clientId?: string;
436
+ /** Granted scope (space-joined) — drives the `id_token` profile claims. */
437
+ scope?: string;
438
+ /** OIDC `nonce` from the authorize request — echoed into the `id_token` (Tier 2). */
439
+ nonce?: string;
440
+ /** Mint an `id_token` at `/token` (Tier 2). */
441
+ idToken?: boolean;
442
+ /** Mint an access token at `/token`. Omitted ⇒ minted (Tier-1 loopback). */
443
+ accessToken?: boolean;
444
+ /** The `id_token` `aud` (the registered `client_id`). */
445
+ audience?: string;
201
446
  /** What `/token` mints when this code is redeemed. */
202
447
  tokenPolicy: TokenPolicy;
203
448
  expiresAt: number;
@@ -208,6 +453,11 @@ interface NewAuthCode {
208
453
  codeChallenge: string;
209
454
  redirectUri: string;
210
455
  clientId?: string;
456
+ scope?: string;
457
+ nonce?: string;
458
+ idToken?: boolean;
459
+ accessToken?: boolean;
460
+ audience?: string;
211
461
  tokenPolicy: TokenPolicy;
212
462
  }
213
463
  /**
@@ -249,4 +499,4 @@ declare class AuthCodeStoreMemory extends AuthCodeStore {
249
499
  consume(code: string): Promise<AuthCode | null>;
250
500
  }
251
501
  //#endregion
252
- export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri };
502
+ export { type AuthCode, AuthCodeStore, AuthCodeStoreMemory, type AuthCodeStoreMemoryOptions, AuthorizeError, type AuthorizeErrorCode, type ClientRedirectPolicy, CompositeClientPolicy, type CompositeClientPolicyOptions, type IdTokenAlg, type IdTokenClaims, IdTokenSigner, type IdTokenSignerOptions, LoopbackClientPolicy, type LoopbackClientPolicyOptions, type NewAuthCode, type NewPendingAuthorization, NoopOidcClaimsResolver, OidcClaimsResolver, type PendingAuthorization, PendingAuthorizationStore, PendingAuthorizationStoreMemory, type PendingAuthorizationStoreMemoryOptions, type RegisteredClient, RegisteredClientPolicy, type RegisteredClientPolicyOptions, type ResolvedClient, type TokenPolicy, isLoopbackRedirectUri, scopeGrants };
package/dist/authz.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
2
- import { randomUUID } from "node:crypto";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { SignJWT, exportJWK, importPKCS8, importSPKI } from "jose";
3
4
  //#region src/authz/authz-errors.ts
4
5
  /** A typed authorization-server failure. */
5
6
  var AuthorizeError = class extends Error {
@@ -51,11 +52,205 @@ var LoopbackClientPolicy = class {
51
52
  if (!isLoopbackRedirectUri(args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri must be a loopback address");
52
53
  return {
53
54
  redirectUri: args.redirectUri,
54
- tokenPolicy: structuredClone(this.tokenPolicy)
55
+ tokenPolicy: structuredClone(this.tokenPolicy),
56
+ ...args.scope !== void 0 && { scope: args.scope }
55
57
  };
56
58
  }
57
59
  };
58
60
  //#endregion
61
+ //#region src/authz/oidc-claims-resolver.ts
62
+ /**
63
+ * Resolves the OIDC profile claims to embed in an `id_token` for a given user +
64
+ * granted scope (AUTH-SERVER.md §4.9). The authorization server already controls
65
+ * the registered claims (`iss`/`aud`/`sub`/`iat`/`exp`/`nonce`) itself — this seam
66
+ * supplies the **profile** claims that depend on the consumer's user shape
67
+ * (`email`/`email_verified`/`name`/`picture`), which `@aooth/auth` cannot know.
68
+ *
69
+ * Pluggable like every other store/policy: the no-op default ({@link NoopOidcClaimsResolver},
70
+ * `sub`-only tokens) ships, and a consumer subclasses it to read its own user
71
+ * record. Bound in `@aooth/auth-moost` under `OIDC_CLAIMS_RESOLVER_TOKEN`.
72
+ *
73
+ * The map's keys are standard OIDC claim names; values MUST be JSON-safe. Honour
74
+ * the granted `scope` — only emit `email`/`email_verified` under `email`, and
75
+ * `name`/`picture`/etc. under `profile`, so a client receives only what it asked
76
+ * for (and was allowed).
77
+ */
78
+ var OidcClaimsResolver = class {};
79
+ /** Default resolver: emits no profile claims, so the `id_token` carries only `sub` + the registered claims. */
80
+ var NoopOidcClaimsResolver = class extends OidcClaimsResolver {
81
+ resolveClaims() {
82
+ return {};
83
+ }
84
+ };
85
+ /** `true` when `scope` (space-joined) grants `claim` — `"email"`/`"profile"` etc. */
86
+ function scopeGrants(scope, claim) {
87
+ if (!scope) return false;
88
+ return scope.split(/\s+/u).includes(claim);
89
+ }
90
+ //#endregion
91
+ //#region src/authz/registered-client-policy.ts
92
+ /**
93
+ * Tier-2 policy: a static registry of first-party clients. `resolveClient`
94
+ * authorizes the client + `redirect_uri` (against the registered allowlist) and
95
+ * resolves the granted scope + what the grant delivers (`id_token`/`access_token`,
96
+ * `aud = client_id`); `authenticateClient` authenticates the client at `/token`
97
+ * (`client_secret` for confidential clients; PKCE is the binding for public ones).
98
+ * An unregistered client or an unlisted redirect is rejected.
99
+ */
100
+ var RegisteredClientPolicy = class {
101
+ clients = /* @__PURE__ */ new Map();
102
+ constructor(opts) {
103
+ for (const c of opts.clients) this.clients.set(c.clientId, c);
104
+ }
105
+ resolveClient(args) {
106
+ const client = this.requireClient(args.clientId);
107
+ if (!this.redirectAllowed(client, args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri is not registered for this client");
108
+ const scope = this.grantScope(client, args.scope);
109
+ const idToken = client.idToken !== false && scopeGrants(scope, "openid");
110
+ return {
111
+ clientId: client.clientId,
112
+ redirectUri: args.redirectUri,
113
+ audience: client.clientId,
114
+ idToken,
115
+ accessToken: client.accessToken === true,
116
+ tokenPolicy: client.tokenPolicy ? structuredClone(client.tokenPolicy) : {},
117
+ ...scope !== void 0 && { scope }
118
+ };
119
+ }
120
+ authenticateClient(args) {
121
+ const client = this.requireClient(args.clientId);
122
+ if ((client.type ?? "public") !== "confidential") return;
123
+ if (!client.clientSecret || !args.clientSecret || !timingSafeEqualStr(args.clientSecret, client.clientSecret)) throw new AuthorizeError("invalid_client", "client authentication failed");
124
+ }
125
+ requireClient(clientId) {
126
+ const client = clientId ? this.clients.get(clientId) : void 0;
127
+ if (!client) throw new AuthorizeError("invalid_client", "unknown client");
128
+ return client;
129
+ }
130
+ redirectAllowed(client, uri) {
131
+ if (client.redirectUris?.includes(uri)) return true;
132
+ if (!client.redirectPrefixes?.length) return false;
133
+ let normalized;
134
+ try {
135
+ normalized = new URL(uri).href;
136
+ } catch {
137
+ return false;
138
+ }
139
+ return client.redirectPrefixes.some((p) => {
140
+ if (p.length === 0 || !normalized.startsWith(p)) return false;
141
+ if (p.endsWith("/")) return true;
142
+ const next = normalized[p.length];
143
+ return next === void 0 || next === "/" || next === "?" || next === "#";
144
+ });
145
+ }
146
+ grantScope(client, requested) {
147
+ if (!requested) return void 0;
148
+ const req = requested.split(/\s+/u).filter(Boolean);
149
+ const granted = client.scopes ? req.filter((s) => client.scopes.includes(s)) : req;
150
+ return granted.length > 0 ? granted.join(" ") : void 0;
151
+ }
152
+ };
153
+ /** Constant-time string compare that also fails closed on a length mismatch. */
154
+ function timingSafeEqualStr(a, b) {
155
+ const ab = Buffer.from(a, "utf8");
156
+ const bb = Buffer.from(b, "utf8");
157
+ if (ab.length !== bb.length) return false;
158
+ return timingSafeEqual(ab, bb);
159
+ }
160
+ //#endregion
161
+ //#region src/authz/composite-client-policy.ts
162
+ /**
163
+ * Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
164
+ * `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
165
+ * registered client, one without is a loopback CLI. The split is the safety
166
+ * boundary — a registered client is routed only to {@link RegisteredClientPolicy}
167
+ * (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
168
+ * redirect), and a no-`client_id` request is routed only to the loopback policy
169
+ * (so it cannot claim to be a registered client). Each sub-policy still owns its
170
+ * own redirect validation; this only picks which one runs.
171
+ */
172
+ var CompositeClientPolicy = class {
173
+ loopback;
174
+ registered;
175
+ constructor(opts) {
176
+ this.loopback = opts.loopback;
177
+ this.registered = opts.registered;
178
+ }
179
+ resolveClient(args) {
180
+ return args.clientId ? this.registered.resolveClient(args) : this.loopback.resolveClient(args);
181
+ }
182
+ authenticateClient(args) {
183
+ if (args.clientId) return this.registered.authenticateClient?.(args);
184
+ }
185
+ };
186
+ //#endregion
187
+ //#region src/authz/id-token-signer.ts
188
+ /**
189
+ * Signs OIDC `id_token`s and publishes the matching JWKS (AUTH-SERVER.md §4.9).
190
+ * Holds one asymmetric keypair; mints short-lived RS256/ES256 tokens with the
191
+ * issuer + audience + subject a relying `OidcProvider` validates, and exports the
192
+ * public half as a JWKS for `GET /auth/jwks`. Keys are imported lazily and cached
193
+ * (the Apple-client-secret pattern), so construction is cheap and synchronous.
194
+ *
195
+ * Never used for the access token (that stays in `AuthCredential`'s store) — the
196
+ * `id_token` is a separate, audience-bound identity assertion.
197
+ */
198
+ var IdTokenSigner = class {
199
+ /** The `iss` claim + discovery `issuer` (read by `/.well-known/openid-configuration`). */
200
+ issuer;
201
+ /** Signature algorithm — published in discovery's `id_token_signing_alg_values_supported`. */
202
+ alg;
203
+ /** Key id — the JWS header + JWKS entry `kid`. */
204
+ kid;
205
+ privateKeyPem;
206
+ publicKeyPem;
207
+ ttlSec;
208
+ clock;
209
+ privateKeyPromise;
210
+ jwksPromise;
211
+ constructor(opts) {
212
+ this.issuer = opts.issuer.replace(/\/$/u, "");
213
+ this.alg = opts.alg ?? "RS256";
214
+ this.kid = opts.kid;
215
+ this.privateKeyPem = opts.privateKey;
216
+ this.publicKeyPem = opts.publicKey;
217
+ this.ttlSec = opts.ttlSec ?? 300;
218
+ this.clock = opts.clock ?? defaultClock;
219
+ }
220
+ /** Mint a signed `id_token` JWT. Iat/exp come from the injected clock. */
221
+ async sign(claims) {
222
+ const key = await this.importPrivateKey();
223
+ const nowSec = Math.floor(this.clock.now() / 1e3);
224
+ const ttl = claims.ttlSec ?? this.ttlSec;
225
+ const payload = { ...claims.extra };
226
+ if (claims.nonce !== void 0) payload.nonce = claims.nonce;
227
+ return new SignJWT(payload).setProtectedHeader({
228
+ alg: this.alg,
229
+ kid: this.kid,
230
+ typ: "JWT"
231
+ }).setIssuer(this.issuer).setSubject(claims.sub).setAudience(claims.aud).setIssuedAt(nowSec).setExpirationTime(nowSec + ttl).sign(key);
232
+ }
233
+ /**
234
+ * The JWKS document served at `/auth/jwks` — the public key as a single
235
+ * `use: "sig"` entry tagged with the same `kid`/`alg` as the minted tokens, so
236
+ * a verifier selects it by `kid`. Computed once and cached.
237
+ */
238
+ async jwks() {
239
+ this.jwksPromise ??= (async () => {
240
+ const jwk = await exportJWK(await importSPKI(this.publicKeyPem, this.alg, { extractable: true }));
241
+ jwk.kid = this.kid;
242
+ jwk.alg = this.alg;
243
+ jwk.use = "sig";
244
+ return { keys: [jwk] };
245
+ })();
246
+ return this.jwksPromise;
247
+ }
248
+ importPrivateKey() {
249
+ this.privateKeyPromise ??= importPKCS8(this.privateKeyPem, this.alg);
250
+ return this.privateKeyPromise;
251
+ }
252
+ };
253
+ //#endregion
59
254
  //#region src/authz/pending-authorization-store.ts
60
255
  /**
61
256
  * Storage seam for in-flight authorizations (AUTH-SERVER.md §4.3). Short-lived
@@ -90,7 +285,11 @@ var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
90
285
  expiresAt: now + this.ttlMs,
91
286
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
92
287
  ...rec.clientState !== void 0 && { clientState: rec.clientState },
93
- ...rec.scope !== void 0 && { scope: rec.scope }
288
+ ...rec.scope !== void 0 && { scope: rec.scope },
289
+ ...rec.nonce !== void 0 && { nonce: rec.nonce },
290
+ ...rec.idToken !== void 0 && { idToken: rec.idToken },
291
+ ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
292
+ ...rec.audience !== void 0 && { audience: rec.audience }
94
293
  };
95
294
  this.store.set(row.handle, structuredClone(row));
96
295
  return { handle: row.handle };
@@ -144,7 +343,12 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
144
343
  redirectUri: rec.redirectUri,
145
344
  tokenPolicy: structuredClone(rec.tokenPolicy),
146
345
  expiresAt: this.clock.now() + this.ttlMs,
147
- ...rec.clientId !== void 0 && { clientId: rec.clientId }
346
+ ...rec.clientId !== void 0 && { clientId: rec.clientId },
347
+ ...rec.scope !== void 0 && { scope: rec.scope },
348
+ ...rec.nonce !== void 0 && { nonce: rec.nonce },
349
+ ...rec.idToken !== void 0 && { idToken: rec.idToken },
350
+ ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
351
+ ...rec.audience !== void 0 && { audience: rec.audience }
148
352
  };
149
353
  this.store.set(code, structuredClone(row));
150
354
  return { code };
@@ -158,4 +362,4 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
158
362
  }
159
363
  };
160
364
  //#endregion
161
- export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, LoopbackClientPolicy, PendingAuthorizationStore, PendingAuthorizationStoreMemory, isLoopbackRedirectUri };
365
+ export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, CompositeClientPolicy, IdTokenSigner, LoopbackClientPolicy, NoopOidcClaimsResolver, OidcClaimsResolver, PendingAuthorizationStore, PendingAuthorizationStoreMemory, RegisteredClientPolicy, isLoopbackRedirectUri, scopeGrants };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -74,17 +74,17 @@
74
74
  },
75
75
  "dependencies": {
76
76
  "jose": "^6.2.3",
77
- "@aooth/user": "0.1.8"
77
+ "@aooth/user": "0.1.9"
78
78
  },
79
79
  "devDependencies": {
80
- "@atscript/core": "^0.1.69",
81
- "@atscript/db": "^0.1.96",
82
- "@atscript/db-sql-tools": "^0.1.96",
83
- "@atscript/db-sqlite": "^0.1.96",
84
- "@atscript/typescript": "^0.1.69",
80
+ "@atscript/core": "^0.1.70",
81
+ "@atscript/db": "^0.1.97",
82
+ "@atscript/db-sql-tools": "^0.1.97",
83
+ "@atscript/db-sqlite": "^0.1.97",
84
+ "@atscript/typescript": "^0.1.70",
85
85
  "@types/better-sqlite3": "^7.6.13",
86
86
  "better-sqlite3": "^12.6.2",
87
- "unplugin-atscript": "^0.1.69"
87
+ "unplugin-atscript": "^0.1.70"
88
88
  },
89
89
  "peerDependencies": {
90
90
  "@atscript/db": ">=0.1.79"