@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,90 @@
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
+ * The data an introspection lookup returns for a token (RFC 7662 §2.2). All
14
+ * members are optional except what the caller chooses to expose; the handler
15
+ * maps these camelCase fields to the snake_case response members and decides
16
+ * `active` from `revoked`/`expiresAt` unless {@link active} is set explicitly.
17
+ */
18
+ export interface IntrospectionTokenRecord {
19
+ /**
20
+ * Explicit active flag. When omitted, the handler derives liveness from
21
+ * {@link revoked} and {@link expiresAt}; set `false` to force inactive.
22
+ */
23
+ readonly active?: boolean;
24
+ /** Whether the token has been revoked — forces `active: false`. */
25
+ readonly revoked?: boolean;
26
+ /** Space-separated granted scopes (`scope`). */
27
+ readonly scope?: string;
28
+ /** The client the token was issued to (`client_id`). */
29
+ readonly clientId?: string;
30
+ /** Human-readable resource-owner identifier (`username`). */
31
+ readonly username?: string;
32
+ /** Token type, e.g. `"Bearer"` or `"DPoP"` (`token_type`). */
33
+ readonly tokenType?: string;
34
+ /** Expiry, seconds since the epoch (`exp`). */
35
+ readonly expiresAt?: number;
36
+ /** Issued-at, seconds since the epoch (`iat`). */
37
+ readonly issuedAt?: number;
38
+ /** Not-before, seconds since the epoch (`nbf`). */
39
+ readonly notBefore?: number;
40
+ /** Subject identifier (`sub`). */
41
+ readonly subject?: string;
42
+ /** Intended audience (`aud`). */
43
+ readonly audience?: string | readonly string[];
44
+ /** Issuer identifier (`iss`). */
45
+ readonly issuer?: string;
46
+ /** Unique token identifier (`jti`). */
47
+ readonly tokenId?: string;
48
+ /**
49
+ * RFC 9449 DPoP confirmation: the proof-key thumbprint bound to the token.
50
+ * Surfaced as `cnf: { jkt }` so a Resource Server can complete the binding.
51
+ */
52
+ readonly jkt?: string;
53
+ }
54
+ /** A pushed authorization request awaiting redemption (RFC 9126). */
55
+ export interface PushedRequestRecord {
56
+ /** The opaque `request_uri` reference (without the URN prefix). */
57
+ readonly reference: string;
58
+ /** The client that pushed the request. */
59
+ readonly clientId: string;
60
+ /** The pushed authorization parameters (form fields), as key→value. */
61
+ readonly params: Readonly<Record<string, string>>;
62
+ /** Expiry, seconds since the epoch. */
63
+ readonly expiresAt: number;
64
+ /** DPoP key thumbprint, when the PAR was DPoP-bound (RFC 9449 §10). */
65
+ readonly jkt?: string;
66
+ }
67
+ /** Storage for pushed authorization requests (RFC 9126). */
68
+ export interface PushedAuthorizationStore {
69
+ /** Persist a freshly pushed request, keyed by its `reference`. */
70
+ save(record: PushedRequestRecord): Promise<void>;
71
+ /**
72
+ * Atomically fetch and invalidate a pushed request by `reference`, returning
73
+ * it only if it was still present and unexpired at `now`. Returns `null`
74
+ * otherwise, so a `request_uri` is single-use even under concurrent
75
+ * authorization requests (enforce this with a conditional delete/`RETURNING`).
76
+ */
77
+ consume(reference: string, now: number): Promise<PushedRequestRecord | null>;
78
+ }
79
+ /** A registered OAuth client record (RFC 7591). */
80
+ export interface ClientRecord {
81
+ /** The issued client identifier. */
82
+ readonly clientId: string;
83
+ /** Issued-at, seconds since the epoch (`client_id_issued_at`). */
84
+ readonly clientIdIssuedAt: number;
85
+ /** Hashed/opaque client secret for confidential clients, if any. */
86
+ readonly clientSecret?: string;
87
+ /** The validated, normalized client metadata that was registered. */
88
+ readonly metadata: Readonly<Record<string, unknown>>;
89
+ }
90
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,mEAAmE;IACnE,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,gDAAgD;IAChD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,kCAAkC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,iCAAiC;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAC/C,iCAAiC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,4DAA4D;AAC5D,MAAM,WAAW,wBAAwB;IACvC,kEAAkE;IAClE,IAAI,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD;;;;;OAKG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAC;CAC9E;AAED,mDAAmD;AACnD,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,kEAAkE;IAClE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,oEAAoE;IACpE,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACtD"}
package/dist/store.js ADDED
@@ -0,0 +1,13 @@
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
+ export {};
13
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@dwk/oauth",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "OAuth 2.0 server building blocks: RFC 8414 metadata, RFC 7662 introspection, RFC 7009 revocation, RFC 9126 PAR, RFC 7591 dynamic client registration. Cross-standard reusable; no Workers runtime dependency.",
5
+ "keywords": [
6
+ "oauth2",
7
+ "rfc8414",
8
+ "rfc7662",
9
+ "rfc7009",
10
+ "rfc9126",
11
+ "rfc7591",
12
+ "introspection",
13
+ "revocation",
14
+ "par",
15
+ "dynamic-client-registration",
16
+ "dpop"
17
+ ],
18
+ "type": "module",
19
+ "license": "ISC",
20
+ "author": "David W. Keith <me@dwk.io>",
21
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/oauth#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/davidwkeith/workers.git",
25
+ "directory": "packages/oauth"
26
+ },
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "!src/**/*.test.ts"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@dwk/dpop": "0.1.0-beta.0",
46
+ "@dwk/log": "0.1.0-beta.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "typecheck": "tsc -p tsconfig.json",
51
+ "clean": "rm -rf dist"
52
+ }
53
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * base64url + random-identifier helpers.
3
+ *
4
+ * Kept dependency-free and runtime-agnostic (Web Crypto / `btoa` only) so the
5
+ * surrounding modules unit-test without a Workers runtime.
6
+ */
7
+
8
+ /** Encode bytes as unpadded base64url (RFC 4648 §5). */
9
+ export function bytesToBase64url(bytes: Uint8Array): string {
10
+ let binary = "";
11
+ for (const byte of bytes) binary += String.fromCharCode(byte);
12
+ return btoa(binary)
13
+ .replace(/\+/g, "-")
14
+ .replace(/\//g, "_")
15
+ .replace(/=+$/, "");
16
+ }
17
+
18
+ /**
19
+ * A cryptographically-random unpadded-base64url identifier of `bytes` entropy
20
+ * (default 32 bytes = 256 bits). Used for `request_uri` references, generated
21
+ * `client_id`s, and client secrets, where unguessability is the security
22
+ * property.
23
+ */
24
+ export function randomIdentifier(bytes = 32): string {
25
+ return bytesToBase64url(crypto.getRandomValues(new Uint8Array(bytes)));
26
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The shared OAuth 2.0 error registry.
3
+ *
4
+ * OAuth defines a fixed vocabulary of `error` codes across its specs; emitting a
5
+ * non-registered code (the finding in [#39]) makes a server fail conformance and
6
+ * confuses standards-compliant clients, which switch on these exact strings.
7
+ * Centralizing them here lets `@dwk/indieauth` and the eventual Solid-OIDC OP
8
+ * share one audited registry rather than re-spelling literals per call site.
9
+ *
10
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.2 (token endpoint)
11
+ * @see https://www.rfc-editor.org/rfc/rfc7591#section-3.2.2 (registration)
12
+ * @see https://www.rfc-editor.org/rfc/rfc9449#section-12 (DPoP)
13
+ */
14
+
15
+ /** The registered OAuth 2.0 `error` codes this lib emits. */
16
+ export const OAuthError = {
17
+ // RFC 6749 §5.2 — token endpoint / general protocol errors.
18
+ /** The request is missing a parameter, malformed, or otherwise invalid. */
19
+ InvalidRequest: "invalid_request",
20
+ /** Client authentication failed or the client is unknown. */
21
+ InvalidClient: "invalid_client",
22
+ /** The grant (code, refresh token, …) is invalid, expired, or revoked. */
23
+ InvalidGrant: "invalid_grant",
24
+ /** The authenticated client is not authorized to use this grant type. */
25
+ UnauthorizedClient: "unauthorized_client",
26
+ /** The grant type is not supported by the authorization server. */
27
+ UnsupportedGrantType: "unsupported_grant_type",
28
+ /** The requested scope is invalid, unknown, or exceeds what was granted. */
29
+ InvalidScope: "invalid_scope",
30
+ /** An unexpected server-side condition prevented fulfilling the request. */
31
+ ServerError: "server_error",
32
+ /** The server is temporarily overloaded or under maintenance. */
33
+ TemporarilyUnavailable: "temporarily_unavailable",
34
+
35
+ // RFC 7591 §3.2.2 — dynamic client registration.
36
+ /** One or more submitted client metadata fields are invalid. */
37
+ InvalidClientMetadata: "invalid_client_metadata",
38
+ /** One or more `redirect_uris` are invalid. */
39
+ InvalidRedirectUri: "invalid_redirect_uri",
40
+
41
+ // RFC 7009 §2.2.1 — revocation.
42
+ /** The server does not support revoking the presented token type. */
43
+ UnsupportedTokenType: "unsupported_token_type",
44
+
45
+ // RFC 9126 §2.3 — pushed authorization requests.
46
+ /** The `request_uri` is unknown, expired, or already consumed. */
47
+ InvalidRequestUri: "invalid_request_uri",
48
+
49
+ // RFC 9449 §5 / §12 — DPoP.
50
+ /** The DPoP proof is missing or fails verification. */
51
+ InvalidDpopProof: "invalid_dpop_proof",
52
+ } as const;
53
+
54
+ /** Union of the registered error-code string literals in {@link OAuthError}. */
55
+ export type OAuthErrorCode = (typeof OAuthError)[keyof typeof OAuthError];
56
+
57
+ const JSON_HEADERS = { "content-type": "application/json" } as const;
58
+
59
+ /**
60
+ * Build an OAuth 2.0 error response (RFC 6749 §5.2): a JSON body with `error`
61
+ * and optional `error_description`. `error` is typed to the registry so a
62
+ * non-registered code cannot be emitted by accident.
63
+ *
64
+ * Extra headers (e.g. `WWW-Authenticate` for a `401`) are merged in; callers
65
+ * MUST keep `error_description` free of secrets and untrusted echoes.
66
+ */
67
+ export function oauthErrorResponse(
68
+ error: OAuthErrorCode,
69
+ description?: string,
70
+ status = 400,
71
+ headers?: Record<string, string>,
72
+ ): Response {
73
+ const body = description
74
+ ? { error, error_description: description }
75
+ : { error };
76
+ return new Response(JSON.stringify(body), {
77
+ status,
78
+ headers: { ...JSON_HEADERS, ...headers },
79
+ });
80
+ }
package/src/http.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Small request/response helpers shared by the four endpoint handlers. Web
3
+ * Fetch types only (`Request`/`Response`/`URLSearchParams`) so the handlers run
4
+ * and test under plain Node.
5
+ */
6
+
7
+ const JSON_HEADERS = { "content-type": "application/json" } as const;
8
+
9
+ /** Serialize `body` as a JSON response with the given status. */
10
+ export function json(body: unknown, status = 200): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: JSON_HEADERS,
14
+ });
15
+ }
16
+
17
+ /** A `405 Method Not Allowed` carrying the permitted methods in `Allow`. */
18
+ export function methodNotAllowed(allow: string): Response {
19
+ return new Response("Method Not Allowed", {
20
+ status: 405,
21
+ headers: { allow },
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Read an `application/x-www-form-urlencoded` body into `URLSearchParams`. A
27
+ * malformed/empty body or wrong content-type yields empty params (never throws),
28
+ * so the caller's own field validation reports the problem as a `400` rather
29
+ * than the handler crashing.
30
+ */
31
+ export async function readForm(request: Request): Promise<URLSearchParams> {
32
+ const params = new URLSearchParams();
33
+ try {
34
+ const form = await request.formData();
35
+ for (const [key, value] of form) {
36
+ if (typeof value === "string") params.set(key, value);
37
+ }
38
+ } catch {
39
+ // Intentionally swallowed — see the doc comment.
40
+ }
41
+ return params;
42
+ }
43
+
44
+ /** Parse a JSON request body, returning `undefined` if it is absent/malformed. */
45
+ export async function readJson(request: Request): Promise<unknown> {
46
+ try {
47
+ return (await request.json()) as unknown;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `@dwk/oauth` — OAuth 2.0 authorization-server building blocks.
3
+ *
4
+ * A cross-standard reusable lib (like `@dwk/dpop`, `@dwk/log`): it provides the
5
+ * shared OAuth 2.0 *server* primitives that `@dwk/indieauth` already partly
6
+ * implements and that the eventual Solid-OIDC OP will need, so they share one
7
+ * audited implementation rather than diverging. It owns the protocol mechanics
8
+ * of four stateful endpoints plus the metadata document and the error registry;
9
+ * it stays **protocol-agnostic** (no IndieWeb/Solid claim handling) and
10
+ * **runtime-agnostic** (plain-data storage seams, Web Fetch + Web Crypto only),
11
+ * so it unit-tests under plain Node without a Workers runtime.
12
+ *
13
+ * What it provides:
14
+ * - **RFC 8414** authorization-server metadata document (config → JSON), the
15
+ * single source of truth shared with the static document Anglesite publishes.
16
+ * - **RFC 7662** token introspection (protected; derives `active`, maps claims,
17
+ * surfaces DPoP `cnf.jkt`).
18
+ * - **RFC 7009** token revocation (idempotent, always `200`).
19
+ * - **RFC 9126** pushed authorization requests (single-use `request_uri`,
20
+ * optional DPoP binding via `@dwk/dpop`).
21
+ * - **RFC 7591** dynamic client registration (strict metadata validation).
22
+ * - A shared, registered **OAuth error registry**.
23
+ *
24
+ * Storage is never owned here: token/client/pushed-request records flow through
25
+ * the plain-data seams in {@link module:store}, which the consuming endpoint
26
+ * package backs with a strongly-consistent store (D1/DO via `@dwk/store`) —
27
+ * never KV.
28
+ *
29
+ * @see spec/packages/oauth.md
30
+ * @packageDocumentation
31
+ */
32
+
33
+ export { OAuthError, oauthErrorResponse, type OAuthErrorCode } from "./errors";
34
+
35
+ export {
36
+ buildAuthorizationServerMetadata,
37
+ type AuthorizationServerMetadata,
38
+ type AuthorizationServerMetadataConfig,
39
+ } from "./metadata";
40
+
41
+ export {
42
+ createIntrospectionHandler,
43
+ isTokenActive,
44
+ buildIntrospectionResponse,
45
+ type IntrospectionConfig,
46
+ type IntrospectionResponse,
47
+ type EndpointAuthenticator,
48
+ } from "./introspection";
49
+
50
+ export { createRevocationHandler, type RevocationConfig } from "./revocation";
51
+
52
+ export {
53
+ createPushedAuthorizationRequestHandler,
54
+ parseRequestUri,
55
+ requestUriFor,
56
+ PUSHED_REQUEST_URI_PREFIX,
57
+ type PushedAuthorizationRequestConfig,
58
+ } from "./par";
59
+
60
+ export {
61
+ createClientRegistrationHandler,
62
+ validateClientMetadata,
63
+ type ClientRegistrationConfig,
64
+ } from "./registration";
65
+
66
+ export type {
67
+ IntrospectionTokenRecord,
68
+ PushedRequestRecord,
69
+ PushedAuthorizationStore,
70
+ ClientRecord,
71
+ } from "./store";
72
+
73
+ export { OAuthLogEvent } from "./log";
74
+ export type { ObservabilityConfig } from "./observability";
75
+ export type { Logger, Metrics } from "@dwk/log";
@@ -0,0 +1,185 @@
1
+ /**
2
+ * OAuth 2.0 Token Introspection (RFC 7662).
3
+ *
4
+ * A protected POST endpoint a Resource Server queries to learn whether a token
5
+ * is currently active and, if so, its metadata. The endpoint **MUST** be
6
+ * protected (RFC 7662 §2.1) so it cannot be used as a token-scanning oracle —
7
+ * hence {@link IntrospectionConfig.authenticate} is required, not optional.
8
+ *
9
+ * The lib owns only the protocol mechanics: it asks the caller to look the token
10
+ * up (storage stays in the consuming package, see `./store`), derives the
11
+ * `active` flag, and maps the record to the snake_case response. A DPoP-bound
12
+ * token surfaces its key thumbprint as `cnf: { jkt }` so the RS can complete the
13
+ * proof-of-possession binding via `@dwk/dpop`.
14
+ *
15
+ * @see https://www.rfc-editor.org/rfc/rfc7662
16
+ */
17
+
18
+ import { hostFromUrl } from "@dwk/log";
19
+
20
+ import { OAuthError, oauthErrorResponse } from "./errors";
21
+ import { json, methodNotAllowed, readForm } from "./http";
22
+ import { OAuthLogEvent } from "./log";
23
+ import {
24
+ emit,
25
+ resolveObservability,
26
+ type ObservabilityConfig,
27
+ } from "./observability";
28
+ import type { IntrospectionTokenRecord } from "./store";
29
+
30
+ /**
31
+ * Authenticates the calling Resource Server / client at a protected endpoint.
32
+ *
33
+ * Receives the request and, when the handler could extract one from the body,
34
+ * the requested `client_id`. The handler passes a **pre-parse clone** of the
35
+ * request, so an authenticator that itself reads the body (e.g. a
36
+ * `client_secret_post` credential, or matching the authenticated client against
37
+ * `clientId` per RFC 9126 §2.1) does not disturb the handler's own parse.
38
+ */
39
+ export type EndpointAuthenticator = (
40
+ request: Request,
41
+ clientId?: string,
42
+ ) => boolean | Promise<boolean>;
43
+
44
+ /** Configuration for {@link createIntrospectionHandler}. */
45
+ export interface IntrospectionConfig extends ObservabilityConfig {
46
+ /**
47
+ * Look up the presented token, returning the record to introspect or `null`
48
+ * if it is unknown. The optional `tokenTypeHint` is the client's
49
+ * `token_type_hint` (RFC 7662 §2.1) — a non-binding optimization the store may
50
+ * ignore. Storage is the consuming package's concern; this stays plain-data.
51
+ */
52
+ readonly lookupToken: (
53
+ token: string,
54
+ tokenTypeHint?: string,
55
+ ) => Promise<IntrospectionTokenRecord | null>;
56
+ /**
57
+ * Authenticate the caller. **Required** — an unprotected introspection
58
+ * endpoint is a token-scanning oracle (RFC 7662 §2.1). Return `false` to
59
+ * reject with `401 invalid_client`.
60
+ */
61
+ readonly authenticate: EndpointAuthenticator;
62
+ /** Current time (seconds since the epoch). Defaults to `Date.now()`. */
63
+ readonly now?: () => number;
64
+ }
65
+
66
+ /** The JSON shape of an introspection response (RFC 7662 §2.2). */
67
+ export interface IntrospectionResponse {
68
+ readonly active: boolean;
69
+ readonly scope?: string;
70
+ readonly client_id?: string;
71
+ readonly username?: string;
72
+ readonly token_type?: string;
73
+ readonly exp?: number;
74
+ readonly iat?: number;
75
+ readonly nbf?: number;
76
+ readonly sub?: string;
77
+ readonly aud?: string | readonly string[];
78
+ readonly iss?: string;
79
+ readonly jti?: string;
80
+ readonly cnf?: { readonly jkt: string };
81
+ }
82
+
83
+ /** The single inactive response RFC 7662 §2.2 mandates for any non-active token. */
84
+ const INACTIVE: IntrospectionResponse = { active: false };
85
+
86
+ /**
87
+ * Whether `record` represents a currently-active token at `now`. A record is
88
+ * active unless it is `null`, explicitly `active: false`, revoked, past its
89
+ * `expiresAt`, or before its `notBefore`. Pure and side-effect-free.
90
+ */
91
+ export function isTokenActive(
92
+ record: IntrospectionTokenRecord | null,
93
+ now: number,
94
+ ): boolean {
95
+ if (record === null) return false;
96
+ if (record.active === false) return false;
97
+ if (record.revoked === true) return false;
98
+ if (record.expiresAt !== undefined && now >= record.expiresAt) return false;
99
+ if (record.notBefore !== undefined && now < record.notBefore) return false;
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * Map an active token record to its RFC 7662 response, emitting only members the
105
+ * record actually carries (so the response never advertises empty claims). The
106
+ * caller MUST have already established the token is active.
107
+ */
108
+ export function buildIntrospectionResponse(
109
+ record: IntrospectionTokenRecord,
110
+ ): IntrospectionResponse {
111
+ const response: Record<string, unknown> = { active: true };
112
+ if (record.scope !== undefined) response.scope = record.scope;
113
+ if (record.clientId !== undefined) response.client_id = record.clientId;
114
+ if (record.username !== undefined) response.username = record.username;
115
+ if (record.tokenType !== undefined) response.token_type = record.tokenType;
116
+ if (record.expiresAt !== undefined) response.exp = record.expiresAt;
117
+ if (record.issuedAt !== undefined) response.iat = record.issuedAt;
118
+ if (record.notBefore !== undefined) response.nbf = record.notBefore;
119
+ if (record.subject !== undefined) response.sub = record.subject;
120
+ if (record.audience !== undefined) response.aud = record.audience;
121
+ if (record.issuer !== undefined) response.iss = record.issuer;
122
+ if (record.tokenId !== undefined) response.jti = record.tokenId;
123
+ if (record.jkt !== undefined) response.cnf = { jkt: record.jkt };
124
+ return response as unknown as IntrospectionResponse;
125
+ }
126
+
127
+ /**
128
+ * Create the introspection endpoint handler. The returned handler accepts a
129
+ * `POST` request and returns the introspection JSON; route it at whatever path
130
+ * the deployer mounts (it is path-agnostic).
131
+ */
132
+ export function createIntrospectionHandler(
133
+ config: IntrospectionConfig,
134
+ ): (request: Request) => Promise<Response> {
135
+ const obs = resolveObservability(config);
136
+ const clock = config.now ?? (() => Math.floor(Date.now() / 1000));
137
+
138
+ return async (request) => {
139
+ if (request.method.toUpperCase() !== "POST") {
140
+ return methodNotAllowed("POST");
141
+ }
142
+
143
+ // Clone before consuming the body so the authenticator can read it too.
144
+ const authRequest = request.clone();
145
+ const form = await readForm(request);
146
+
147
+ // Protect the endpoint first, so an unauthenticated caller learns nothing
148
+ // about any token (not even "unknown" vs "known") — the token lookup stays
149
+ // after this gate. Reading the (small) form body up front is not sensitive.
150
+ const clientId = form.get("client_id") ?? undefined;
151
+ if (!(await config.authenticate(authRequest, clientId))) {
152
+ emit(obs, "warn", OAuthLogEvent.IntrospectionRejected, {
153
+ reason: "unauthenticated",
154
+ });
155
+ return oauthErrorResponse(
156
+ OAuthError.InvalidClient,
157
+ "introspection requires client authentication",
158
+ 401,
159
+ { "WWW-Authenticate": "Bearer" },
160
+ );
161
+ }
162
+
163
+ const token = form.get("token") ?? "";
164
+ if (!token) {
165
+ return oauthErrorResponse(
166
+ OAuthError.InvalidRequest,
167
+ "`token` is required",
168
+ );
169
+ }
170
+ const hint = form.get("token_type_hint") ?? undefined;
171
+
172
+ const record = await config.lookupToken(token, hint);
173
+ if (!isTokenActive(record, clock())) {
174
+ emit(obs, "info", OAuthLogEvent.IntrospectionInactive);
175
+ return json(INACTIVE);
176
+ }
177
+
178
+ // `record` is non-null here: isTokenActive(null) is false.
179
+ const active = record as IntrospectionTokenRecord;
180
+ emit(obs, "info", OAuthLogEvent.IntrospectionActive, {
181
+ clientHost: active.clientId ? hostFromUrl(active.clientId) : undefined,
182
+ });
183
+ return json(buildIntrospectionResponse(active));
184
+ };
185
+ }
package/src/log.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@dwk/oauth` — structured observability event taxonomy.
3
+ *
4
+ * These endpoints sit on the security-sensitive edge of an authorization server:
5
+ * a refused introspection (token scanning), a rejected registration, or a
6
+ * consumed/forged `request_uri` is exactly the kind of event an operator needs a
7
+ * signal for. Logging and metrics are opt-in via an injected {@link Logger} and
8
+ * {@link Metrics} (see `@dwk/log`) and **share this one vocabulary** — the same
9
+ * dotted event name is passed to the logger and the metrics sink so a log line
10
+ * and its counter line up.
11
+ *
12
+ * Fields follow the redaction policy: a `client_id` URL is reduced to its host
13
+ * via `hostFromUrl`, and tokens, secrets, codes, and `request_uri` reference
14
+ * values are **never** logged — only machine-readable reason codes, hosts, and
15
+ * scopes.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ /** Stable event names emitted by `@dwk/oauth`. */
21
+ export const OAuthLogEvent = {
22
+ /** A token was introspected and reported active. */
23
+ IntrospectionActive: "oauth.introspection.active",
24
+ /** A token was introspected and reported inactive (unknown/expired/revoked). */
25
+ IntrospectionInactive: "oauth.introspection.inactive",
26
+ /** An introspection request was refused (failed endpoint authentication). */
27
+ IntrospectionRejected: "oauth.introspection.rejected",
28
+ /** A token was revoked (or the no-op revocation of an unknown token). */
29
+ TokenRevoked: "oauth.revocation.revoked",
30
+ /** A revocation request was refused (failed endpoint authentication). */
31
+ RevocationRejected: "oauth.revocation.rejected",
32
+ /** A pushed authorization request was stored and a `request_uri` issued. */
33
+ PushedRequestStored: "oauth.par.stored",
34
+ /** A pushed authorization request was rejected before storage. Field: `reason`. */
35
+ PushedRequestRejected: "oauth.par.rejected",
36
+ /** A dynamic client registration succeeded. Field: `clientHost`. */
37
+ ClientRegistered: "oauth.registration.registered",
38
+ /** A dynamic client registration was rejected. Field: `reason`. */
39
+ ClientRegistrationRejected: "oauth.registration.rejected",
40
+ } as const;
41
+
42
+ /** Union of the event-name string literals in {@link OAuthLogEvent}. */
43
+ export type OAuthLogEvent = (typeof OAuthLogEvent)[keyof typeof OAuthLogEvent];