@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,133 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Server Metadata (RFC 8414).
3
+ *
4
+ * One source of truth drives two consumers: the **static** metadata document
5
+ * Anglesite publishes at `/.well-known/oauth-authorization-server` (derived from
6
+ * config at build time), and any **runtime** behaviour that must agree with what
7
+ * was advertised. The builder is pure config→JSON; it is protocol-agnostic, so
8
+ * IndieAuth, a Solid-OIDC OP, or a bare OAuth AS all feed it their own endpoints
9
+ * and supported-value lists.
10
+ *
11
+ * @see https://www.rfc-editor.org/rfc/rfc8414
12
+ */
13
+
14
+ /** Configuration for {@link buildAuthorizationServerMetadata}. */
15
+ export interface AuthorizationServerMetadataConfig {
16
+ /** The authorization-server issuer identifier (a URL, no query/fragment). */
17
+ readonly issuer: string;
18
+ /** Absolute authorization endpoint URL. */
19
+ readonly authorizationEndpoint?: string;
20
+ /** Absolute token endpoint URL. */
21
+ readonly tokenEndpoint?: string;
22
+ /** Absolute token-introspection endpoint URL (RFC 7662). */
23
+ readonly introspectionEndpoint?: string;
24
+ /** Absolute token-revocation endpoint URL (RFC 7009). */
25
+ readonly revocationEndpoint?: string;
26
+ /** Absolute pushed-authorization-request endpoint URL (RFC 9126). */
27
+ readonly pushedAuthorizationRequestEndpoint?: string;
28
+ /** Absolute dynamic-client-registration endpoint URL (RFC 7591). */
29
+ readonly registrationEndpoint?: string;
30
+ /** Absolute JWK Set document URL. */
31
+ readonly jwksUri?: string;
32
+ /** OAuth scopes the server supports. Omitted from the document when empty. */
33
+ readonly scopesSupported?: readonly string[];
34
+ /** Supported `response_type` values. Defaults to `["code"]`. */
35
+ readonly responseTypesSupported?: readonly string[];
36
+ /** Supported `grant_type` values. Defaults to `["authorization_code"]`. */
37
+ readonly grantTypesSupported?: readonly string[];
38
+ /** Supported token-endpoint client authentication methods. Defaults to `["none"]`. */
39
+ readonly tokenEndpointAuthMethodsSupported?: readonly string[];
40
+ /** Supported PKCE `code_challenge` methods. Defaults to `["S256"]`. */
41
+ readonly codeChallengeMethodsSupported?: readonly string[];
42
+ /** Supported DPoP proof signing algorithms (RFC 9449), if DPoP is offered. */
43
+ readonly dpopSigningAlgValuesSupported?: readonly string[];
44
+ /**
45
+ * Whether the AS *requires* PAR for every authorization request (RFC 9126
46
+ * §4). Only emitted (as `true`) when a PAR endpoint is configured.
47
+ */
48
+ readonly requirePushedAuthorizationRequests?: boolean;
49
+ }
50
+
51
+ /**
52
+ * The JSON shape of the authorization-server metadata document. Snake-cased per
53
+ * RFC 8414; optional members are omitted (not `null`) when not configured.
54
+ */
55
+ export interface AuthorizationServerMetadata {
56
+ readonly issuer: string;
57
+ readonly authorization_endpoint?: string;
58
+ readonly token_endpoint?: string;
59
+ readonly introspection_endpoint?: string;
60
+ readonly revocation_endpoint?: string;
61
+ readonly pushed_authorization_request_endpoint?: string;
62
+ readonly require_pushed_authorization_requests?: boolean;
63
+ readonly registration_endpoint?: string;
64
+ readonly jwks_uri?: string;
65
+ readonly scopes_supported?: readonly string[];
66
+ readonly response_types_supported: readonly string[];
67
+ readonly grant_types_supported: readonly string[];
68
+ readonly token_endpoint_auth_methods_supported: readonly string[];
69
+ readonly code_challenge_methods_supported: readonly string[];
70
+ readonly dpop_signing_alg_values_supported?: readonly string[];
71
+ }
72
+
73
+ /**
74
+ * Build the RFC 8414 metadata document from config. `issuer`,
75
+ * `response_types_supported`, `grant_types_supported`,
76
+ * `token_endpoint_auth_methods_supported`, and `code_challenge_methods_supported`
77
+ * are always present (with defaults); every endpoint URL and the optional value
78
+ * lists are emitted only when supplied, so the document never advertises an
79
+ * endpoint the deployer did not mount.
80
+ */
81
+ export function buildAuthorizationServerMetadata(
82
+ config: AuthorizationServerMetadataConfig,
83
+ ): AuthorizationServerMetadata {
84
+ const metadata: Record<string, unknown> = {
85
+ issuer: config.issuer,
86
+ response_types_supported: config.responseTypesSupported ?? ["code"],
87
+ grant_types_supported: config.grantTypesSupported ?? ["authorization_code"],
88
+ token_endpoint_auth_methods_supported:
89
+ config.tokenEndpointAuthMethodsSupported ?? ["none"],
90
+ code_challenge_methods_supported: config.codeChallengeMethodsSupported ?? [
91
+ "S256",
92
+ ],
93
+ };
94
+
95
+ if (config.authorizationEndpoint !== undefined) {
96
+ metadata.authorization_endpoint = config.authorizationEndpoint;
97
+ }
98
+ if (config.tokenEndpoint !== undefined) {
99
+ metadata.token_endpoint = config.tokenEndpoint;
100
+ }
101
+ if (config.introspectionEndpoint !== undefined) {
102
+ metadata.introspection_endpoint = config.introspectionEndpoint;
103
+ }
104
+ if (config.revocationEndpoint !== undefined) {
105
+ metadata.revocation_endpoint = config.revocationEndpoint;
106
+ }
107
+ if (config.pushedAuthorizationRequestEndpoint !== undefined) {
108
+ metadata.pushed_authorization_request_endpoint =
109
+ config.pushedAuthorizationRequestEndpoint;
110
+ // `require_*` is only meaningful alongside the endpoint that satisfies it.
111
+ if (config.requirePushedAuthorizationRequests) {
112
+ metadata.require_pushed_authorization_requests = true;
113
+ }
114
+ }
115
+ if (config.registrationEndpoint !== undefined) {
116
+ metadata.registration_endpoint = config.registrationEndpoint;
117
+ }
118
+ if (config.jwksUri !== undefined) {
119
+ metadata.jwks_uri = config.jwksUri;
120
+ }
121
+ if (config.scopesSupported && config.scopesSupported.length > 0) {
122
+ metadata.scopes_supported = config.scopesSupported;
123
+ }
124
+ if (
125
+ config.dpopSigningAlgValuesSupported &&
126
+ config.dpopSigningAlgValuesSupported.length > 0
127
+ ) {
128
+ metadata.dpop_signing_alg_values_supported =
129
+ config.dpopSigningAlgValuesSupported;
130
+ }
131
+
132
+ return metadata as unknown as AuthorizationServerMetadata;
133
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The shared logging/metrics seam used by every endpoint. Logging and metrics
3
+ * are opt-in (default no-op) and share one event vocabulary (see `./log` and
4
+ * `@dwk/log`): the same dotted event name flows to both the logger and the
5
+ * metrics sink so a log line and its counter line up.
6
+ */
7
+
8
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
9
+ import type { LogFields } from "@dwk/log";
10
+
11
+ /** Observability config fragment mixed into each handler's config. */
12
+ export interface ObservabilityConfig {
13
+ /**
14
+ * Logger for endpoint events; defaults to a no-op. Wire a real logger (see
15
+ * `@dwk/log`) to surface introspection refusals, registration rejections, and
16
+ * the like instead of swallowing them.
17
+ */
18
+ readonly logger?: Logger;
19
+ /**
20
+ * Metrics sink for the same events; defaults to a no-op. Wire an adapter (e.g.
21
+ * `analyticsEngineMetrics` from `@dwk/log`) to chart what the logger names.
22
+ */
23
+ readonly metrics?: Metrics;
24
+ }
25
+
26
+ /** A resolved logger/metrics pair, defaults applied. */
27
+ export interface Observability {
28
+ readonly logger: Logger;
29
+ readonly metrics: Metrics;
30
+ }
31
+
32
+ /** Resolve the optional observability config to concrete (no-op) sinks. */
33
+ export function resolveObservability(
34
+ config: ObservabilityConfig,
35
+ ): Observability {
36
+ return {
37
+ logger: config.logger ?? noopLogger,
38
+ metrics: config.metrics ?? noopMetrics,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Emit a structured event on both the logger and the metrics sink. `warn` for
44
+ * handled-but-notable rejections, `info` for normal outcomes. Honors the
45
+ * redaction policy — callers pass only reason codes, sanitized hosts, and
46
+ * scopes, never tokens, secrets, or `request_uri` references.
47
+ */
48
+ export function emit(
49
+ obs: Observability,
50
+ level: "info" | "warn",
51
+ event: string,
52
+ fields?: LogFields,
53
+ ): void {
54
+ obs.logger[level](event, fields);
55
+ obs.metrics.count(event, fields);
56
+ }
package/src/par.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pushed Authorization Requests (RFC 9126).
3
+ *
4
+ * The client POSTs its authorization-request parameters directly to this
5
+ * endpoint; the server validates and stores them and hands back a short-lived,
6
+ * single-use `request_uri`. The client then starts the normal authorization
7
+ * flow with just `client_id` + `request_uri`, so the request parameters never
8
+ * travel through the browser/redirect and cannot be tampered with.
9
+ *
10
+ * This lib owns the *push* side (validate → store → mint `request_uri`). The
11
+ * *consume* side lives at the authorization endpoint in the consuming package:
12
+ * use {@link parseRequestUri} to recover the reference and the store's
13
+ * single-use `consume` ({@link PushedAuthorizationStore}). When DPoP binding is
14
+ * enabled and the push carries a `DPoP` header, the proof is verified via
15
+ * `@dwk/dpop` and its `jkt` recorded so the eventual token is key-bound
16
+ * (RFC 9449 §10).
17
+ *
18
+ * @see https://www.rfc-editor.org/rfc/rfc9126
19
+ */
20
+
21
+ import { verifyDpopProof } from "@dwk/dpop";
22
+ import { hostFromUrl } from "@dwk/log";
23
+
24
+ import { randomIdentifier } from "./encoding";
25
+ import { OAuthError, oauthErrorResponse } from "./errors";
26
+ import { json, methodNotAllowed, readForm } from "./http";
27
+ import { OAuthLogEvent } from "./log";
28
+ import {
29
+ emit,
30
+ resolveObservability,
31
+ type ObservabilityConfig,
32
+ } from "./observability";
33
+ import type { EndpointAuthenticator } from "./introspection";
34
+ import type { PushedRequestRecord } from "./store";
35
+
36
+ /** The URN prefix RFC 9126 §2.2 mandates for a PAR `request_uri`. */
37
+ export const PUSHED_REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
38
+
39
+ const DEFAULT_LIFETIME_SECONDS = 60;
40
+ /** Reference entropy in bytes: 256 bits of unguessable `request_uri`. */
41
+ const REFERENCE_BYTES = 32;
42
+
43
+ /** Build the `request_uri` URN for a stored reference. */
44
+ export function requestUriFor(reference: string): string {
45
+ return `${PUSHED_REQUEST_URI_PREFIX}${reference}`;
46
+ }
47
+
48
+ /**
49
+ * Recover the opaque reference from a PAR `request_uri`, or `null` if `uri` is
50
+ * not a `urn:ietf:params:oauth:request_uri:` value. Use this at the
51
+ * authorization endpoint before calling the store's single-use `consume`.
52
+ */
53
+ export function parseRequestUri(uri: string): string | null {
54
+ if (!uri.startsWith(PUSHED_REQUEST_URI_PREFIX)) return null;
55
+ const reference = uri.slice(PUSHED_REQUEST_URI_PREFIX.length);
56
+ return reference.length > 0 ? reference : null;
57
+ }
58
+
59
+ /** Configuration for {@link createPushedAuthorizationRequestHandler}. */
60
+ export interface PushedAuthorizationRequestConfig extends ObservabilityConfig {
61
+ /** Persist a freshly pushed request (keyed by its `reference`). */
62
+ readonly saveRequest: (record: PushedRequestRecord) => Promise<void>;
63
+ /**
64
+ * Optionally authenticate the caller (RFC 9126 §2: clients authenticate as at
65
+ * the token endpoint). Omit for public clients. Return `false` to reject with
66
+ * `401 invalid_client`.
67
+ */
68
+ readonly authenticate?: EndpointAuthenticator;
69
+ /**
70
+ * Optional extra validation of the pushed parameters (e.g. requiring PKCE or a
71
+ * known `response_type`). Return an error description string to reject with
72
+ * `400 invalid_request`, or `null` to accept.
73
+ */
74
+ readonly validate?: (
75
+ params: Readonly<Record<string, string>>,
76
+ ) => string | null | Promise<string | null>;
77
+ /** `request_uri` lifetime in seconds. Defaults to 60 (RFC 9126 favors short). */
78
+ readonly lifetimeSeconds?: number;
79
+ /**
80
+ * Enable RFC 9449 DPoP binding: when `true` and the push carries a `DPoP`
81
+ * header, the proof is verified and its `jkt` recorded on the request. The
82
+ * `htu` is bound to {@link endpoint} (falling back to the request URL).
83
+ */
84
+ readonly dpopBinding?: boolean;
85
+ /** Absolute PAR endpoint URL, used as the DPoP `htu`. */
86
+ readonly endpoint?: string;
87
+ /** Current time (seconds since the epoch). Defaults to `Date.now()`. */
88
+ readonly now?: () => number;
89
+ }
90
+
91
+ /**
92
+ * Create the pushed-authorization-request endpoint handler. On success it
93
+ * returns `201` with `{ request_uri, expires_in }` (RFC 9126 §2.2).
94
+ */
95
+ export function createPushedAuthorizationRequestHandler(
96
+ config: PushedAuthorizationRequestConfig,
97
+ ): (request: Request) => Promise<Response> {
98
+ const obs = resolveObservability(config);
99
+ const clock = config.now ?? (() => Math.floor(Date.now() / 1000));
100
+ const lifetime = config.lifetimeSeconds ?? DEFAULT_LIFETIME_SECONDS;
101
+
102
+ return async (request) => {
103
+ if (request.method.toUpperCase() !== "POST") {
104
+ return methodNotAllowed("POST");
105
+ }
106
+
107
+ // Clone before consuming the body so the authenticator can read it too.
108
+ const authRequest = request.clone();
109
+ const form = await readForm(request);
110
+
111
+ // RFC 9126 §2.1: the PAR body MUST NOT itself contain a `request_uri`.
112
+ if (form.has("request_uri")) {
113
+ emit(obs, "warn", OAuthLogEvent.PushedRequestRejected, {
114
+ reason: "request_uri_present",
115
+ });
116
+ return oauthErrorResponse(
117
+ OAuthError.InvalidRequest,
118
+ "`request_uri` is not allowed in a pushed authorization request",
119
+ );
120
+ }
121
+
122
+ const clientId = form.get("client_id") ?? "";
123
+ if (!clientId) {
124
+ emit(obs, "warn", OAuthLogEvent.PushedRequestRejected, {
125
+ reason: "client_id_missing",
126
+ });
127
+ return oauthErrorResponse(
128
+ OAuthError.InvalidRequest,
129
+ "`client_id` is required",
130
+ );
131
+ }
132
+
133
+ // Authenticate with the extracted `client_id` in hand, so the authenticator
134
+ // can enforce the RFC 9126 §2.1 match (authenticated client == client_id).
135
+ if (
136
+ config.authenticate &&
137
+ !(await config.authenticate(authRequest, clientId))
138
+ ) {
139
+ emit(obs, "warn", OAuthLogEvent.PushedRequestRejected, {
140
+ reason: "unauthenticated",
141
+ });
142
+ return oauthErrorResponse(
143
+ OAuthError.InvalidClient,
144
+ "pushed authorization requests require client authentication",
145
+ 401,
146
+ { "WWW-Authenticate": "Bearer" },
147
+ );
148
+ }
149
+
150
+ const params: Record<string, string> = {};
151
+ for (const [key, value] of form) params[key] = value;
152
+
153
+ if (config.validate) {
154
+ const problem = await config.validate(params);
155
+ if (problem !== null && problem !== undefined) {
156
+ emit(obs, "warn", OAuthLogEvent.PushedRequestRejected, {
157
+ reason: "validation_failed",
158
+ clientHost: hostFromUrl(clientId),
159
+ });
160
+ return oauthErrorResponse(OAuthError.InvalidRequest, problem);
161
+ }
162
+ }
163
+
164
+ let jkt: string | undefined;
165
+ if (config.dpopBinding) {
166
+ const proof = request.headers.get("DPoP");
167
+ if (proof) {
168
+ const dpop = await verifyDpopProof({
169
+ proof,
170
+ htm: "POST",
171
+ htu: config.endpoint ?? request.url,
172
+ });
173
+ if (!dpop.valid || !dpop.jkt) {
174
+ emit(obs, "warn", OAuthLogEvent.PushedRequestRejected, {
175
+ reason: "dpop_invalid",
176
+ clientHost: hostFromUrl(clientId),
177
+ });
178
+ return oauthErrorResponse(
179
+ OAuthError.InvalidDpopProof,
180
+ `DPoP proof verification failed: ${dpop.reason ?? "unknown"}`,
181
+ );
182
+ }
183
+ jkt = dpop.jkt;
184
+ }
185
+ }
186
+
187
+ const reference = randomIdentifier(REFERENCE_BYTES);
188
+ const record: PushedRequestRecord = {
189
+ reference,
190
+ clientId,
191
+ params,
192
+ expiresAt: clock() + lifetime,
193
+ ...(jkt ? { jkt } : {}),
194
+ };
195
+ await config.saveRequest(record);
196
+
197
+ emit(obs, "info", OAuthLogEvent.PushedRequestStored, {
198
+ clientHost: hostFromUrl(clientId),
199
+ });
200
+ return json(
201
+ { request_uri: requestUriFor(reference), expires_in: lifetime },
202
+ 201,
203
+ );
204
+ };
205
+ }