@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 +213 -3
- package/dist/authz.d.cts +253 -3
- package/dist/authz.d.mts +253 -3
- package/dist/authz.mjs +209 -5
- package/package.json +8 -8
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
77
|
+
"@aooth/user": "0.1.9"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@atscript/core": "^0.1.
|
|
81
|
-
"@atscript/db": "^0.1.
|
|
82
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
83
|
-
"@atscript/db-sqlite": "^0.1.
|
|
84
|
-
"@atscript/typescript": "^0.1.
|
|
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.
|
|
87
|
+
"unplugin-atscript": "^0.1.70"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
90
|
"@atscript/db": ">=0.1.79"
|