@dwk/oauth 0.1.0-beta.0

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.
Files changed (63) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +175 -0
  3. package/dist/encoding.d.ts +16 -0
  4. package/dist/encoding.d.ts.map +1 -0
  5. package/dist/encoding.js +26 -0
  6. package/dist/encoding.js.map +1 -0
  7. package/dist/errors.d.ts +54 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +66 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/http.d.ts +19 -0
  12. package/dist/http.d.ts.map +1 -0
  13. package/dist/http.js +50 -0
  14. package/dist/http.js.map +1 -0
  15. package/dist/index.d.ts +42 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +39 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/introspection.d.ts +83 -0
  20. package/dist/introspection.d.ts.map +1 -0
  21. package/dist/introspection.js +118 -0
  22. package/dist/introspection.js.map +1 -0
  23. package/dist/log.d.ts +42 -0
  24. package/dist/log.d.ts.map +1 -0
  25. package/dist/log.js +40 -0
  26. package/dist/log.js.map +1 -0
  27. package/dist/metadata.d.ts +79 -0
  28. package/dist/metadata.d.ts.map +1 -0
  29. package/dist/metadata.js +67 -0
  30. package/dist/metadata.js.map +1 -0
  31. package/dist/observability.d.ts +37 -0
  32. package/dist/observability.d.ts.map +1 -0
  33. package/dist/observability.js +25 -0
  34. package/dist/observability.js.map +1 -0
  35. package/dist/par.d.ts +67 -0
  36. package/dist/par.d.ts.map +1 -0
  37. package/dist/par.js +132 -0
  38. package/dist/par.js.map +1 -0
  39. package/dist/registration.d.ts +71 -0
  40. package/dist/registration.d.ts.map +1 -0
  41. package/dist/registration.js +258 -0
  42. package/dist/registration.js.map +1 -0
  43. package/dist/revocation.d.ts +35 -0
  44. package/dist/revocation.d.ts.map +1 -0
  45. package/dist/revocation.js +50 -0
  46. package/dist/revocation.js.map +1 -0
  47. package/dist/store.d.ts +90 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/store.js +13 -0
  50. package/dist/store.js.map +1 -0
  51. package/package.json +53 -0
  52. package/src/encoding.ts +26 -0
  53. package/src/errors.ts +80 -0
  54. package/src/http.ts +51 -0
  55. package/src/index.ts +75 -0
  56. package/src/introspection.ts +185 -0
  57. package/src/log.ts +43 -0
  58. package/src/metadata.ts +133 -0
  59. package/src/observability.ts +56 -0
  60. package/src/par.ts +205 -0
  61. package/src/registration.ts +336 -0
  62. package/src/revocation.ts +92 -0
  63. package/src/store.ts +93 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * OAuth 2.0 Dynamic Client Registration (RFC 7591).
3
+ *
4
+ * A POST endpoint where a client submits its metadata and receives an issued
5
+ * `client_id` (and, for confidential clients, a `client_secret`). The lib
6
+ * validates the metadata against the registered standard fields and the
7
+ * server's supported value lists, normalizes defaults, mints the identifier(s),
8
+ * and delegates persistence to {@link ClientRegistrationConfig.saveClient}.
9
+ *
10
+ * Validation is deliberately strict on the security-relevant fields
11
+ * (`redirect_uris`, `token_endpoint_auth_method`, and the
12
+ * grant/response-type pairing) and ignores unrecognized members rather than
13
+ * echoing arbitrary client-supplied data back as registered metadata.
14
+ *
15
+ * @see https://www.rfc-editor.org/rfc/rfc7591
16
+ */
17
+
18
+ import { hostFromUrl } from "@dwk/log";
19
+
20
+ import { randomIdentifier } from "./encoding";
21
+ import { OAuthError, oauthErrorResponse, type OAuthErrorCode } from "./errors";
22
+ import { json, methodNotAllowed, readJson } from "./http";
23
+ import { OAuthLogEvent } from "./log";
24
+ import {
25
+ emit,
26
+ resolveObservability,
27
+ type ObservabilityConfig,
28
+ } from "./observability";
29
+ import type { EndpointAuthenticator } from "./introspection";
30
+ import type { ClientRecord } from "./store";
31
+
32
+ const DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
33
+ const DEFAULT_RESPONSE_TYPES = ["code"] as const;
34
+ const DEFAULT_AUTH_METHODS = [
35
+ "none",
36
+ "client_secret_basic",
37
+ "client_secret_post",
38
+ ] as const;
39
+
40
+ /** Configuration for {@link createClientRegistrationHandler}. */
41
+ export interface ClientRegistrationConfig extends ObservabilityConfig {
42
+ /** Persist a newly registered client record. */
43
+ readonly saveClient: (record: ClientRecord) => Promise<void>;
44
+ /**
45
+ * Optionally authenticate the caller (RFC 7591 §3: an "initial access token"
46
+ * may gate open registration). Omit to allow open registration. Return
47
+ * `false` to reject with `401 invalid_client`.
48
+ */
49
+ readonly authenticate?: EndpointAuthenticator;
50
+ /** Mint the `client_id`. Defaults to a 256-bit random base64url string. */
51
+ readonly generateClientId?: () => string;
52
+ /** Mint the `client_secret`. Defaults to a 256-bit random base64url string. */
53
+ readonly generateClientSecret?: () => string;
54
+ /**
55
+ * Extra redirect-URI policy applied after structural validation (e.g. an
56
+ * allowlist of hosts). Return `false` to reject with `invalid_redirect_uri`.
57
+ */
58
+ readonly redirectUriPolicy?: (uri: string) => boolean;
59
+ /** Grant types the server allows. Defaults to authorization_code + refresh_token. */
60
+ readonly grantTypesSupported?: readonly string[];
61
+ /** Response types the server allows. Defaults to `["code"]`. */
62
+ readonly responseTypesSupported?: readonly string[];
63
+ /** Token-endpoint auth methods the server allows. Defaults to none/basic/post. */
64
+ readonly tokenEndpointAuthMethodsSupported?: readonly string[];
65
+ /** Current time (seconds since the epoch). Defaults to `Date.now()`. */
66
+ readonly now?: () => number;
67
+ }
68
+
69
+ /** A validation failure: the error code and a human-readable description. */
70
+ interface MetadataError {
71
+ readonly error: OAuthErrorCode;
72
+ readonly description: string;
73
+ }
74
+
75
+ /** Recognized RFC 7591 string-valued metadata members echoed back on success. */
76
+ const STRING_FIELDS = [
77
+ "client_name",
78
+ "client_uri",
79
+ "logo_uri",
80
+ "scope",
81
+ "tos_uri",
82
+ "policy_uri",
83
+ "jwks_uri",
84
+ "software_id",
85
+ "software_version",
86
+ ] as const;
87
+
88
+ function isObject(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value);
90
+ }
91
+
92
+ function isStringArray(value: unknown): value is string[] {
93
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
94
+ }
95
+
96
+ /**
97
+ * Whether `uri` is an acceptable redirect URI: a parseable absolute URL with no
98
+ * fragment component (RFC 7591 §2 / RFC 6749 §3.1.2 — the fragment is reserved
99
+ * for the response and must not be pre-registered).
100
+ */
101
+ function isValidRedirectUri(uri: string): boolean {
102
+ try {
103
+ return new URL(uri).hash === "";
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Validate and normalize submitted client metadata. Returns the normalized
111
+ * metadata (defaults applied, only recognized members retained) or a
112
+ * {@link MetadataError}. Pure and side-effect-free, so it unit-tests directly.
113
+ */
114
+ export function validateClientMetadata(
115
+ input: unknown,
116
+ config: ClientRegistrationConfig,
117
+ ): { readonly metadata: Record<string, unknown> } | MetadataError {
118
+ if (!isObject(input)) {
119
+ return {
120
+ error: OAuthError.InvalidClientMetadata,
121
+ description: "request body must be a JSON object",
122
+ };
123
+ }
124
+
125
+ const authMethods =
126
+ config.tokenEndpointAuthMethodsSupported ?? DEFAULT_AUTH_METHODS;
127
+ const grantTypesSupported = config.grantTypesSupported ?? DEFAULT_GRANT_TYPES;
128
+ const responseTypesSupported =
129
+ config.responseTypesSupported ?? DEFAULT_RESPONSE_TYPES;
130
+
131
+ // token_endpoint_auth_method (default per RFC 7591 §2).
132
+ const authMethod = input.token_endpoint_auth_method ?? "client_secret_basic";
133
+ if (typeof authMethod !== "string" || !authMethods.includes(authMethod)) {
134
+ return {
135
+ error: OAuthError.InvalidClientMetadata,
136
+ description: `unsupported token_endpoint_auth_method: ${String(authMethod)}`,
137
+ };
138
+ }
139
+
140
+ // grant_types / response_types (defaults per RFC 7591 §2).
141
+ const requestedGrants = input.grant_types ?? ["authorization_code"];
142
+ if (!isStringArray(requestedGrants)) {
143
+ return {
144
+ error: OAuthError.InvalidClientMetadata,
145
+ description: "grant_types must be an array of strings",
146
+ };
147
+ }
148
+ for (const grant of requestedGrants) {
149
+ if (!grantTypesSupported.includes(grant)) {
150
+ return {
151
+ error: OAuthError.InvalidClientMetadata,
152
+ description: `unsupported grant_type: ${grant}`,
153
+ };
154
+ }
155
+ }
156
+
157
+ const requestedResponses = input.response_types ?? ["code"];
158
+ if (!isStringArray(requestedResponses)) {
159
+ return {
160
+ error: OAuthError.InvalidClientMetadata,
161
+ description: "response_types must be an array of strings",
162
+ };
163
+ }
164
+ for (const responseType of requestedResponses) {
165
+ if (!responseTypesSupported.includes(responseType)) {
166
+ return {
167
+ error: OAuthError.InvalidClientMetadata,
168
+ description: `unsupported response_type: ${responseType}`,
169
+ };
170
+ }
171
+ }
172
+
173
+ // Grant/response-type consistency (RFC 7591 §2): authorization_code ⇔ code.
174
+ const usesCode = requestedGrants.includes("authorization_code");
175
+ const wantsCode = requestedResponses.includes("code");
176
+ if (usesCode !== wantsCode) {
177
+ return {
178
+ error: OAuthError.InvalidClientMetadata,
179
+ description:
180
+ "grant_types and response_types are inconsistent: " +
181
+ "`authorization_code` requires `code` and vice versa",
182
+ };
183
+ }
184
+
185
+ // redirect_uris: required for redirect-based flows.
186
+ const needsRedirect =
187
+ requestedGrants.includes("authorization_code") ||
188
+ requestedGrants.includes("implicit");
189
+ const redirectUris = input.redirect_uris;
190
+ if (redirectUris !== undefined) {
191
+ if (!isStringArray(redirectUris)) {
192
+ return {
193
+ error: OAuthError.InvalidRedirectUri,
194
+ description: "redirect_uris must be an array of strings",
195
+ };
196
+ }
197
+ for (const uri of redirectUris) {
198
+ if (!isValidRedirectUri(uri)) {
199
+ return {
200
+ error: OAuthError.InvalidRedirectUri,
201
+ description: `invalid redirect_uri: ${uri}`,
202
+ };
203
+ }
204
+ if (config.redirectUriPolicy && !config.redirectUriPolicy(uri)) {
205
+ return {
206
+ error: OAuthError.InvalidRedirectUri,
207
+ description: `redirect_uri not permitted: ${uri}`,
208
+ };
209
+ }
210
+ }
211
+ }
212
+ if (
213
+ needsRedirect &&
214
+ (!isStringArray(redirectUris) || redirectUris.length === 0)
215
+ ) {
216
+ return {
217
+ error: OAuthError.InvalidRedirectUri,
218
+ description: "redirect_uris is required for the requested grant_types",
219
+ };
220
+ }
221
+
222
+ // contacts: optional array of strings.
223
+ if (input.contacts !== undefined && !isStringArray(input.contacts)) {
224
+ return {
225
+ error: OAuthError.InvalidClientMetadata,
226
+ description: "contacts must be an array of strings",
227
+ };
228
+ }
229
+
230
+ // jwks: optional object.
231
+ if (input.jwks !== undefined && !isObject(input.jwks)) {
232
+ return {
233
+ error: OAuthError.InvalidClientMetadata,
234
+ description: "jwks must be a JSON object",
235
+ };
236
+ }
237
+
238
+ // Recognized string fields must be strings when present.
239
+ for (const field of STRING_FIELDS) {
240
+ if (input[field] !== undefined && typeof input[field] !== "string") {
241
+ return {
242
+ error: OAuthError.InvalidClientMetadata,
243
+ description: `${field} must be a string`,
244
+ };
245
+ }
246
+ }
247
+
248
+ // Assemble normalized metadata: defaults applied, only recognized members.
249
+ const metadata: Record<string, unknown> = {
250
+ token_endpoint_auth_method: authMethod,
251
+ grant_types: requestedGrants,
252
+ response_types: requestedResponses,
253
+ };
254
+ if (redirectUris !== undefined) metadata.redirect_uris = redirectUris;
255
+ if (input.contacts !== undefined) metadata.contacts = input.contacts;
256
+ if (input.jwks !== undefined) metadata.jwks = input.jwks;
257
+ for (const field of STRING_FIELDS) {
258
+ if (input[field] !== undefined) metadata[field] = input[field];
259
+ }
260
+
261
+ return { metadata };
262
+ }
263
+
264
+ /**
265
+ * Create the dynamic-client-registration endpoint handler. On success it returns
266
+ * `201` with the client information response (RFC 7591 §3.2.1): the issued
267
+ * `client_id`/`client_id_issued_at`, an optional `client_secret`
268
+ * (+`client_secret_expires_at: 0`, meaning non-expiring) for confidential
269
+ * clients, and the registered metadata.
270
+ */
271
+ export function createClientRegistrationHandler(
272
+ config: ClientRegistrationConfig,
273
+ ): (request: Request) => Promise<Response> {
274
+ const obs = resolveObservability(config);
275
+ const clock = config.now ?? (() => Math.floor(Date.now() / 1000));
276
+ const newClientId = config.generateClientId ?? (() => randomIdentifier());
277
+ const newClientSecret =
278
+ config.generateClientSecret ?? (() => randomIdentifier());
279
+
280
+ return async (request) => {
281
+ if (request.method.toUpperCase() !== "POST") {
282
+ return methodNotAllowed("POST");
283
+ }
284
+
285
+ // Clone so an authenticator that reads the body (e.g. an initial access
286
+ // token in the body) does not disturb the handler's own JSON parse. A
287
+ // registration request carries no `client_id` (one is being issued).
288
+ if (config.authenticate && !(await config.authenticate(request.clone()))) {
289
+ emit(obs, "warn", OAuthLogEvent.ClientRegistrationRejected, {
290
+ reason: "unauthenticated",
291
+ });
292
+ return oauthErrorResponse(
293
+ OAuthError.InvalidClient,
294
+ "client registration requires authorization",
295
+ 401,
296
+ { "WWW-Authenticate": "Bearer" },
297
+ );
298
+ }
299
+
300
+ const body = await readJson(request);
301
+ const result = validateClientMetadata(body, config);
302
+ if ("error" in result) {
303
+ emit(obs, "warn", OAuthLogEvent.ClientRegistrationRejected, {
304
+ reason: result.error,
305
+ });
306
+ return oauthErrorResponse(result.error, result.description);
307
+ }
308
+
309
+ const { metadata } = result;
310
+ const issuedAt = clock();
311
+ const clientId = newClientId();
312
+ const confidential = metadata.token_endpoint_auth_method !== "none";
313
+ const clientSecret = confidential ? newClientSecret() : undefined;
314
+
315
+ const record: ClientRecord = {
316
+ clientId,
317
+ clientIdIssuedAt: issuedAt,
318
+ ...(clientSecret ? { clientSecret } : {}),
319
+ metadata,
320
+ };
321
+ await config.saveClient(record);
322
+
323
+ emit(obs, "info", OAuthLogEvent.ClientRegistered, {
324
+ clientHost: hostFromUrl(clientId),
325
+ });
326
+ const response: Record<string, unknown> = {
327
+ client_id: clientId,
328
+ client_id_issued_at: issuedAt,
329
+ ...(clientSecret
330
+ ? { client_secret: clientSecret, client_secret_expires_at: 0 }
331
+ : {}),
332
+ ...metadata,
333
+ };
334
+ return json(response, 201);
335
+ };
336
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OAuth 2.0 Token Revocation (RFC 7009).
3
+ *
4
+ * A POST endpoint where a client asks the authorization server to invalidate a
5
+ * token. Revocation is **idempotent and forgiving**: an unknown, malformed, or
6
+ * already-revoked token still yields `200` (RFC 7009 §2.2), so a client can
7
+ * retry safely and cannot probe token existence by status code. The lib owns the
8
+ * protocol; the actual invalidation is delegated to
9
+ * {@link RevocationConfig.revokeToken}, backed by the consuming package's store.
10
+ *
11
+ * @see https://www.rfc-editor.org/rfc/rfc7009
12
+ */
13
+
14
+ import { OAuthError, oauthErrorResponse } from "./errors";
15
+ import { methodNotAllowed, readForm } from "./http";
16
+ import { OAuthLogEvent } from "./log";
17
+ import {
18
+ emit,
19
+ resolveObservability,
20
+ type ObservabilityConfig,
21
+ } from "./observability";
22
+ import type { EndpointAuthenticator } from "./introspection";
23
+
24
+ /** Configuration for {@link createRevocationHandler}. */
25
+ export interface RevocationConfig extends ObservabilityConfig {
26
+ /**
27
+ * Revoke the presented token. MUST be idempotent and MUST NOT throw for an
28
+ * unknown token — RFC 7009 §2.2 requires `200` regardless. The optional
29
+ * `tokenTypeHint` is the client's non-binding `token_type_hint`.
30
+ */
31
+ readonly revokeToken: (
32
+ token: string,
33
+ tokenTypeHint?: string,
34
+ ) => Promise<void>;
35
+ /**
36
+ * Optionally authenticate the caller (RFC 7009 §2.1: confidential clients
37
+ * MUST be authenticated). Omit for public clients using the `none` method.
38
+ * Return `false` to reject with `401 invalid_client`.
39
+ */
40
+ readonly authenticate?: EndpointAuthenticator;
41
+ }
42
+
43
+ /**
44
+ * Create the revocation endpoint handler. The returned handler accepts a `POST`
45
+ * request and returns `200` with an empty body on success.
46
+ */
47
+ export function createRevocationHandler(
48
+ config: RevocationConfig,
49
+ ): (request: Request) => Promise<Response> {
50
+ const obs = resolveObservability(config);
51
+
52
+ return async (request) => {
53
+ if (request.method.toUpperCase() !== "POST") {
54
+ return methodNotAllowed("POST");
55
+ }
56
+
57
+ // Clone before consuming the body so the authenticator can read it too.
58
+ const authRequest = request.clone();
59
+ const form = await readForm(request);
60
+
61
+ const clientId = form.get("client_id") ?? undefined;
62
+ if (
63
+ config.authenticate &&
64
+ !(await config.authenticate(authRequest, clientId))
65
+ ) {
66
+ emit(obs, "warn", OAuthLogEvent.RevocationRejected, {
67
+ reason: "unauthenticated",
68
+ });
69
+ return oauthErrorResponse(
70
+ OAuthError.InvalidClient,
71
+ "revocation requires client authentication",
72
+ 401,
73
+ { "WWW-Authenticate": "Bearer" },
74
+ );
75
+ }
76
+
77
+ const token = form.get("token") ?? "";
78
+ // A missing `token` is the one malformed-request case RFC 7009 §2.1 lets us
79
+ // reject; a present-but-unknown token is still a success.
80
+ if (!token) {
81
+ return oauthErrorResponse(
82
+ OAuthError.InvalidRequest,
83
+ "`token` is required",
84
+ );
85
+ }
86
+ const hint = form.get("token_type_hint") ?? undefined;
87
+
88
+ await config.revokeToken(token, hint);
89
+ emit(obs, "info", OAuthLogEvent.TokenRevoked);
90
+ return new Response(null, { status: 200 });
91
+ };
92
+ }
package/src/store.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Plain-data storage seams for the stateful endpoints.
3
+ *
4
+ * Per the design constraint, this lib does **not** own a database: token, client,
5
+ * and pushed-request records are passed in and out through these interfaces, and
6
+ * the consuming endpoint package backs them with a strongly-consistent store
7
+ * (D1 with session consistency, or a Durable Object) via `@dwk/store` —
8
+ * **never KV**, since a stale token/authz record is a security bug. Implementing
9
+ * these against an in-memory `Map` is all a unit test needs, which is why the
10
+ * core runs under plain Node.
11
+ */
12
+
13
+ /**
14
+ * The data an introspection lookup returns for a token (RFC 7662 §2.2). All
15
+ * members are optional except what the caller chooses to expose; the handler
16
+ * maps these camelCase fields to the snake_case response members and decides
17
+ * `active` from `revoked`/`expiresAt` unless {@link active} is set explicitly.
18
+ */
19
+ export interface IntrospectionTokenRecord {
20
+ /**
21
+ * Explicit active flag. When omitted, the handler derives liveness from
22
+ * {@link revoked} and {@link expiresAt}; set `false` to force inactive.
23
+ */
24
+ readonly active?: boolean;
25
+ /** Whether the token has been revoked — forces `active: false`. */
26
+ readonly revoked?: boolean;
27
+ /** Space-separated granted scopes (`scope`). */
28
+ readonly scope?: string;
29
+ /** The client the token was issued to (`client_id`). */
30
+ readonly clientId?: string;
31
+ /** Human-readable resource-owner identifier (`username`). */
32
+ readonly username?: string;
33
+ /** Token type, e.g. `"Bearer"` or `"DPoP"` (`token_type`). */
34
+ readonly tokenType?: string;
35
+ /** Expiry, seconds since the epoch (`exp`). */
36
+ readonly expiresAt?: number;
37
+ /** Issued-at, seconds since the epoch (`iat`). */
38
+ readonly issuedAt?: number;
39
+ /** Not-before, seconds since the epoch (`nbf`). */
40
+ readonly notBefore?: number;
41
+ /** Subject identifier (`sub`). */
42
+ readonly subject?: string;
43
+ /** Intended audience (`aud`). */
44
+ readonly audience?: string | readonly string[];
45
+ /** Issuer identifier (`iss`). */
46
+ readonly issuer?: string;
47
+ /** Unique token identifier (`jti`). */
48
+ readonly tokenId?: string;
49
+ /**
50
+ * RFC 9449 DPoP confirmation: the proof-key thumbprint bound to the token.
51
+ * Surfaced as `cnf: { jkt }` so a Resource Server can complete the binding.
52
+ */
53
+ readonly jkt?: string;
54
+ }
55
+
56
+ /** A pushed authorization request awaiting redemption (RFC 9126). */
57
+ export interface PushedRequestRecord {
58
+ /** The opaque `request_uri` reference (without the URN prefix). */
59
+ readonly reference: string;
60
+ /** The client that pushed the request. */
61
+ readonly clientId: string;
62
+ /** The pushed authorization parameters (form fields), as key→value. */
63
+ readonly params: Readonly<Record<string, string>>;
64
+ /** Expiry, seconds since the epoch. */
65
+ readonly expiresAt: number;
66
+ /** DPoP key thumbprint, when the PAR was DPoP-bound (RFC 9449 §10). */
67
+ readonly jkt?: string;
68
+ }
69
+
70
+ /** Storage for pushed authorization requests (RFC 9126). */
71
+ export interface PushedAuthorizationStore {
72
+ /** Persist a freshly pushed request, keyed by its `reference`. */
73
+ save(record: PushedRequestRecord): Promise<void>;
74
+ /**
75
+ * Atomically fetch and invalidate a pushed request by `reference`, returning
76
+ * it only if it was still present and unexpired at `now`. Returns `null`
77
+ * otherwise, so a `request_uri` is single-use even under concurrent
78
+ * authorization requests (enforce this with a conditional delete/`RETURNING`).
79
+ */
80
+ consume(reference: string, now: number): Promise<PushedRequestRecord | null>;
81
+ }
82
+
83
+ /** A registered OAuth client record (RFC 7591). */
84
+ export interface ClientRecord {
85
+ /** The issued client identifier. */
86
+ readonly clientId: string;
87
+ /** Issued-at, seconds since the epoch (`client_id_issued_at`). */
88
+ readonly clientIdIssuedAt: number;
89
+ /** Hashed/opaque client secret for confidential clients, if any. */
90
+ readonly clientSecret?: string;
91
+ /** The validated, normalized client metadata that was registered. */
92
+ readonly metadata: Readonly<Record<string, unknown>>;
93
+ }