okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Smoke tests for the AS crypto layer — ES256 sign/verify, DPoP mint/verify,
3
+ * thumbprints. These exercise the WebCrypto paths end to end.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createAsKeyStore,
9
+ createDpopSigner,
10
+ decodeDpopProof,
11
+ jwkThumbprint,
12
+ signAccessToken,
13
+ verifyDpopProof,
14
+ verifySignedJwt,
15
+ type OAuthAccessClaims,
16
+ } from "./crypto.ts";
17
+
18
+ const T0 = 1_700_000_000_000;
19
+
20
+ describe("oauth-as crypto", () => {
21
+ test("sign + verify ES256 access token roundtrip", async () => {
22
+ const keys = createAsKeyStore();
23
+ const claims: OAuthAccessClaims = {
24
+ iss: "https://as.example.com",
25
+ sub: "user-1",
26
+ client_id: "https://app.example.com/cimd.json",
27
+ aud: "https://mcp.example.com/mcp",
28
+ scope: "mcp:tools",
29
+ iat: Math.floor(T0 / 1000),
30
+ exp: Math.floor(T0 / 1000) + 600,
31
+ jti: "jti-1",
32
+ cnf: { jkt: "thumb" },
33
+ };
34
+ const token = await signAccessToken(keys, claims);
35
+ const decoded = await verifySignedJwt<OAuthAccessClaims>(keys, token);
36
+ expect(decoded.sub).toBe("user-1");
37
+ expect(decoded.aud).toBe("https://mcp.example.com/mcp");
38
+ expect(decoded.cnf?.jkt).toBe("thumb");
39
+ });
40
+
41
+ test("tampered payload is rejected", async () => {
42
+ const keys = createAsKeyStore();
43
+ const token = await signAccessToken(keys, {
44
+ iss: "iss",
45
+ sub: "s",
46
+ client_id: "c",
47
+ aud: "a",
48
+ scope: "x",
49
+ iat: 1,
50
+ exp: 2,
51
+ jti: "j",
52
+ });
53
+ const parts = token.split(".");
54
+ const forged = `${parts[0]}.${btoa(JSON.stringify({ evil: true }))
55
+ .replace(/\+/g, "-")
56
+ .replace(/\//g, "_")
57
+ .replace(/=+$/, "")}.${parts[2]}`;
58
+ await expect(verifySignedJwt(keys, forged)).rejects.toThrow();
59
+ });
60
+
61
+ test("DPoP proof verifies and binds htm/htu/ath", async () => {
62
+ const signer = await createDpopSigner();
63
+ const accessToken = "some-access-token";
64
+ const proof = await signer.prove({
65
+ htm: "POST",
66
+ htu: "https://as.example.com/oauth/token?x=1#frag",
67
+ now: T0,
68
+ accessToken,
69
+ });
70
+ const { header } = decodeDpopProof(proof);
71
+ const jktFromHeader = await jwkThumbprint(header.jwk ?? {});
72
+ const jwk = await verifyDpopProof(proof, {
73
+ htm: "POST",
74
+ htu: "https://as.example.com/oauth/token",
75
+ now: T0,
76
+ accessToken,
77
+ });
78
+ expect(await jwkThumbprint(jwk)).toBe(jktFromHeader);
79
+ // Wrong method rejected.
80
+ await expect(
81
+ verifyDpopProof(proof, { htm: "GET", htu: "https://as.example.com/oauth/token", now: T0 }),
82
+ ).rejects.toThrow(/htm/);
83
+ // Stale proof (outside skew window) rejected.
84
+ await expect(
85
+ verifyDpopProof(proof, {
86
+ htm: "POST",
87
+ htu: "https://as.example.com/oauth/token",
88
+ now: T0 + 10 * 60_000,
89
+ }),
90
+ ).rejects.toThrow(/iat/);
91
+ });
92
+
93
+ test("jwks exposes the active key", async () => {
94
+ const keys = createAsKeyStore();
95
+ await keys.generate();
96
+ const set = keys.jwks();
97
+ expect(set.keys.length).toBe(1);
98
+ expect(set.keys[0]?.kty).toBe("EC");
99
+ expect(typeof set.keys[0]?.kid).toBe("string");
100
+ });
101
+ });
@@ -0,0 +1,393 @@
1
+ /**
2
+ * AS crypto — ES256 access tokens, JWKS publication, DPoP proof mint/verify.
3
+ *
4
+ * Hand-rolled on WebCrypto (no external OAuth/OIDC library). This module is
5
+ * lazy-loaded: it is imported dynamically by the `mcpOauth` plugin and the
6
+ * MCP RS verifier, so Store-only / non-MCP apps pay zero cost. Never import
7
+ * statically from kernel entrypoints (`fx.ts`, `capability.ts`, cold paths).
8
+ */
9
+
10
+ import { OAuthError } from "./errors.ts";
11
+ import type { SigningKeyRow } from "./tables.ts";
12
+
13
+ /** Default access-token TTL (10 minutes — short-lived per OAuth 2.1 posture). */
14
+ export const OAUTH_ACCESS_TTL_MS = 10 * 60_000;
15
+
16
+ /** Default authorization-code TTL (60 seconds, RFC 6749 §4.1.2). */
17
+ export const OAUTH_CODE_TTL_MS = 60_000;
18
+
19
+ /** Maximum accepted clock skew for DPoP `iat` validation (seconds). */
20
+ export const DPOP_MAX_CLOCK_SKEW_SECONDS = 30;
21
+
22
+ /** Acceptable DPoP `ath` hash mismatch / missing-proof errors surface as this code. */
23
+ export const DPOP_ALG = "ES256";
24
+
25
+ /** JOSE header for our ES256 access tokens. */
26
+ export interface JwtHeader {
27
+ readonly alg: "ES256";
28
+ readonly typ: "JWT";
29
+ /** Key id — matches the active {@link SigningKeyRow.kid}. */
30
+ readonly kid?: string;
31
+ }
32
+
33
+ /**
34
+ * Access-token claims minted by the AS.
35
+ *
36
+ * Audience is the exact canonical RFC 8707 `resource` URI; `cnf.jkt`
37
+ * sender-constrains the token to one DPoP key (RFC 9449).
38
+ */
39
+ export interface OAuthAccessClaims {
40
+ readonly iss: string;
41
+ readonly sub: string;
42
+ readonly client_id: string;
43
+ /** Exact resource URI this token may be presented against. */
44
+ readonly aud: string;
45
+ readonly scope: string;
46
+ readonly iat: number;
47
+ readonly exp: number;
48
+ readonly jti: string;
49
+ readonly cnf?: { readonly jkt: string };
50
+ }
51
+
52
+ /** One DPoP proof-JWT claims set (RFC 9449 §4.2). */
53
+ export interface DpopClaims {
54
+ readonly jti: string;
55
+ readonly htm: string;
56
+ readonly htu: string;
57
+ readonly iat: number;
58
+ /** Access-token `ath` claim — SHA-256 of the presented token (b64url). */
59
+ readonly ath?: string;
60
+ }
61
+
62
+ /** In-memory signing-key store (persisted shape mirrors {@link SigningKeyRow}). */
63
+ export class AsKeyStore {
64
+ private readonly keys = new Map<string, SigningKeyRow>();
65
+
66
+ /**
67
+ * Add a key row (tests / hydration).
68
+ *
69
+ * @param row - Key row
70
+ */
71
+ put(row: SigningKeyRow): void {
72
+ this.keys.set(row.kid, row);
73
+ if (row.active) {
74
+ for (const [kid, other] of this.keys) {
75
+ if (kid !== row.kid && other.active) {
76
+ this.keys.set(kid, { ...other, active: false, rotatedAt: Date.now() });
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ /** The currently active signing key (generates + registers one when absent). */
83
+ async active(): Promise<SigningKeyRow> {
84
+ for (const key of this.keys.values()) {
85
+ if (key.active) return key;
86
+ }
87
+ return this.generate();
88
+ }
89
+
90
+ /**
91
+ * Generate an ES256 keypair and register it as active (rotation: prior
92
+ * keys stay published so outstanding tokens verify during overlap).
93
+ */
94
+ async generate(): Promise<SigningKeyRow> {
95
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
96
+ "sign",
97
+ "verify",
98
+ ]);
99
+ const pub = await exportPublicJwk(pair.publicKey);
100
+ const kid = await jwkThumbprint(pub);
101
+ const row: SigningKeyRow = {
102
+ kid,
103
+ alg: "ES256",
104
+ publicJwk: pub,
105
+ privateKey: pair.privateKey,
106
+ active: true,
107
+ createdAt: Date.now(),
108
+ rotatedAt: null,
109
+ };
110
+ this.put(row);
111
+ return row;
112
+ }
113
+
114
+ /** Public JWK set for `GET /oauth/jwks` — every non-rotated-out key. */
115
+ jwks(): { readonly keys: readonly Record<string, string>[] } {
116
+ return {
117
+ keys: [...this.keys.values()].map((k) => ({ ...k.publicJwk, kid: k.kid, alg: k.alg })),
118
+ };
119
+ }
120
+
121
+ /** Look up a published key by `kid`. */
122
+ byKid(kid: string): SigningKeyRow | undefined {
123
+ return this.keys.get(kid);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Create an empty key store.
129
+ */
130
+ export function createAsKeyStore(): AsKeyStore {
131
+ return new AsKeyStore();
132
+ }
133
+
134
+ /**
135
+ * Sign claims as an ES256 JWT under the active key.
136
+ *
137
+ * @param keys - Key store
138
+ * @param claims - Payload
139
+ */
140
+ export async function signAccessToken(
141
+ keys: AsKeyStore,
142
+ claims: OAuthAccessClaims,
143
+ ): Promise<string> {
144
+ const key = await keys.active();
145
+ const header: JwtHeader = { alg: "ES256", typ: "JWT", kid: key.kid };
146
+ const data = `${b64urlJson(header)}.${b64urlJson(claims)}`;
147
+ const signature = await crypto.subtle.sign(
148
+ { name: "ECDSA", hash: { name: "SHA-256" } },
149
+ key.privateKey,
150
+ new TextEncoder().encode(data),
151
+ );
152
+ return `${data}.${b64urlBytes(new Uint8Array(signature))}`;
153
+ }
154
+
155
+ /**
156
+ * Verify a signed JWT's ES256 signature and decode its payload.
157
+ * Expiry / audience checks belong to callers.
158
+ *
159
+ * @param keys - Key store
160
+ * @param token - Compact JWS
161
+ */
162
+ export async function verifySignedJwt<T>(keys: AsKeyStore, token: string): Promise<T> {
163
+ const parts = token.split(".");
164
+ if (parts.length !== 3) throw new OAuthError("invalid_token", "malformed token");
165
+ const [headerPart, payloadPart, sigPart] = parts as [string, string, string];
166
+ let header: JwtHeader;
167
+ try {
168
+ header = JSON.parse(b64urlDecodeText(headerPart)) as JwtHeader;
169
+ } catch {
170
+ throw new OAuthError("invalid_token", "malformed token header");
171
+ }
172
+ if (header.alg !== "ES256") throw new OAuthError("invalid_token", "unsupported alg");
173
+ const key = header.kid !== undefined ? keys.byKid(header.kid) : await keys.active();
174
+ if (!key) throw new OAuthError("invalid_token", "unknown signing key");
175
+ const ok = await crypto.subtle.verify(
176
+ { name: "ECDSA", hash: { name: "SHA-256" } },
177
+ await importPublicJwk(key.publicJwk),
178
+ b64urlDecodeBytes(sigPart),
179
+ new TextEncoder().encode(`${headerPart}.${payloadPart}`) as Uint8Array<ArrayBuffer>,
180
+ );
181
+ if (!ok) throw new OAuthError("invalid_token", "invalid token signature");
182
+ try {
183
+ return JSON.parse(b64urlDecodeText(payloadPart)) as T;
184
+ } catch {
185
+ throw new OAuthError("invalid_token", "malformed token payload");
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Generate a fresh DPoP signer (client side / tests).
191
+ *
192
+ * @returns P-256 public JWK plus a `prove()` closure over the private key
193
+ */
194
+ export async function createDpopSigner(): Promise<{
195
+ readonly publicJwk: Record<string, string>;
196
+ prove(input: {
197
+ htm: string;
198
+ htu: string;
199
+ now?: number;
200
+ accessToken?: string;
201
+ nonce?: string;
202
+ }): Promise<string>;
203
+ }> {
204
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
205
+ "sign",
206
+ "verify",
207
+ ]);
208
+ const publicJwk = dpopPublicJwk(await exportPublicJwk(pair.publicKey));
209
+ return {
210
+ publicJwk,
211
+ async prove({ htm, htu, now = Date.now(), accessToken, nonce }) {
212
+ const claims: Record<string, unknown> = {
213
+ jti: crypto.randomUUID(),
214
+ htm,
215
+ htu: normalizeHtu(htu),
216
+ iat: Math.floor(now / 1000),
217
+ ...(accessToken !== undefined ? { ath: await sha256B64url(accessToken) } : {}),
218
+ ...(nonce !== undefined ? { nonce } : {}),
219
+ };
220
+ const header = {
221
+ alg: DPOP_ALG,
222
+ typ: "dpop+jwt",
223
+ jwk: stripRfc7517Extras(publicJwk),
224
+ };
225
+ const data = `${b64urlJson(header)}.${b64urlJson(claims)}`;
226
+ const sig = await crypto.subtle.sign(
227
+ { name: "ECDSA", hash: { name: "SHA-256" } },
228
+ pair.privateKey,
229
+ new TextEncoder().encode(data),
230
+ );
231
+ return `${data}.${b64urlBytes(new Uint8Array(sig))}`;
232
+ },
233
+ };
234
+ }
235
+
236
+ /** Decoded DPoP proof parts. */
237
+ export interface DecodedDpopProof {
238
+ readonly header: {
239
+ readonly alg: string;
240
+ readonly typ: string;
241
+ readonly jwk?: Record<string, string>;
242
+ };
243
+ readonly claims: DpopClaims;
244
+ }
245
+
246
+ /**
247
+ * Decode a DPoP proof without verifying (header JWK needed before lookup).
248
+ *
249
+ * @param proof - Compact JWS
250
+ */
251
+ export function decodeDpopProof(proof: string): DecodedDpopProof {
252
+ const parts = proof.split(".");
253
+ if (parts.length !== 3) throw new OAuthError("invalid_request", "malformed DPoP proof");
254
+ try {
255
+ const header = JSON.parse(b64urlDecodeText(parts[0] ?? "")) as DecodedDpopProof["header"];
256
+ const claims = JSON.parse(b64urlDecodeText(parts[1] ?? "")) as DpopClaims;
257
+ return { header, claims };
258
+ } catch {
259
+ throw new OAuthError("invalid_request", "malformed DPoP proof");
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Verify a DPoP proof-JWT end to end (RFC 9449 §4.2):
265
+ * header JWK sanity, ES256 signature, required claims, `iat` window,
266
+ * method/URI binding, and optional `ath` binding to the presented token.
267
+ *
268
+ * @param proof - Compact proof-JWT
269
+ * @param input - Expected HTTP method / target URI, clock, token for `ath`
270
+ */
271
+ export async function verifyDpopProof(
272
+ proof: string,
273
+ input: {
274
+ htm: string;
275
+ htu: string;
276
+ now?: number;
277
+ accessToken?: string;
278
+ },
279
+ ): Promise<Record<string, string>> {
280
+ const { header, claims } = decodeDpopProof(proof);
281
+ const jwk = header.jwk;
282
+ if (!jwk || !jwk.crv || !jwk.x || !jwk.y || jwk.d !== undefined) {
283
+ throw new OAuthError("invalid_request", "DPoP proof jwk must be a public EC key");
284
+ }
285
+ if (jwk.kty !== undefined && jwk.kty !== "EC") {
286
+ throw new OAuthError("invalid_request", "DPoP proof jwk must use kty=EC");
287
+ }
288
+ const nowSeconds = Math.floor((input.now ?? Date.now()) / 1000);
289
+ if (
290
+ typeof claims.iat !== "number" ||
291
+ Math.abs(nowSeconds - claims.iat) > DPOP_MAX_CLOCK_SKEW_SECONDS
292
+ ) {
293
+ throw new OAuthError("invalid_request", "DPoP proof iat outside acceptance window");
294
+ }
295
+ if (claims.htm !== input.htm) {
296
+ throw new OAuthError("invalid_request", "DPoP proof htm mismatch");
297
+ }
298
+ if (normalizeHtu(claims.htu) !== normalizeHtu(input.htu)) {
299
+ throw new OAuthError("invalid_request", "DPoP proof htu mismatch");
300
+ }
301
+ const verified = await crypto.subtle.verify(
302
+ { name: "ECDSA", hash: { name: "SHA-256" } },
303
+ await importPublicJwk(jwk),
304
+ b64urlDecodeBytes(proof.split(".")[2] ?? ""),
305
+ new TextEncoder().encode(
306
+ `${proof.split(".")[0]}.${proof.split(".")[1]}`,
307
+ ) as Uint8Array<ArrayBuffer>,
308
+ );
309
+ if (!verified) throw new OAuthError("invalid_request", "invalid DPoP proof signature");
310
+ if (input.accessToken !== undefined) {
311
+ const expected = await sha256B64url(input.accessToken);
312
+ if (claims.ath !== expected) {
313
+ throw new OAuthError("insufficient_scope", "DPoP ath mismatch");
314
+ }
315
+ }
316
+ return jwk;
317
+ }
318
+
319
+ /**
320
+ * Compute the RFC 7638 JWK thumbprint (`jkt`) used in `cnf.jkt`.
321
+ *
322
+ * @param jwk - Public EC JWK (extra members ignored)
323
+ */
324
+ export async function jwkThumbprint(jwk: Readonly<Record<string, string>>): Promise<string> {
325
+ return sha256B64url(JSON.stringify(stripRfc7517Extras({ ...jwk })));
326
+ }
327
+
328
+ async function importPublicJwk(jwk: Readonly<Record<string, string>>): Promise<CryptoKey> {
329
+ return crypto.subtle.importKey(
330
+ "jwk",
331
+ { ...stripRfc7517Extras({ ...jwk }), key_ops: ["verify"] },
332
+ { name: "ECDSA", namedCurve: "P-256" },
333
+ true,
334
+ ["verify"],
335
+ );
336
+ }
337
+
338
+ async function exportPublicJwk(key: CryptoKey): Promise<Record<string, string>> {
339
+ const exported = await crypto.subtle.exportKey("jwk", key);
340
+ return { ...(exported as Record<string, string>) };
341
+ }
342
+
343
+ function dpopPublicJwk(jwk: Record<string, string>): Record<string, string> {
344
+ return { kty: jwk.kty ?? "EC", crv: jwk.crv ?? "P-256", x: jwk.x ?? "", y: jwk.y ?? "" };
345
+ }
346
+
347
+ function stripRfc7517Extras(jwk: Readonly<Record<string, string>>): Record<string, string> {
348
+ const { kty, crv, x, y } = jwk;
349
+ return { kty: kty ?? "", crv: crv ?? "", x: x ?? "", y: y ?? "" };
350
+ }
351
+
352
+ /** RFC 9449 §4.4 htu normalization: lowercase scheme/host, drop query+fragment. */
353
+ function normalizeHtu(htu: string): string {
354
+ let url: URL;
355
+ try {
356
+ url = new URL(htu);
357
+ } catch {
358
+ throw new OAuthError("invalid_request", "invalid htu");
359
+ }
360
+ url.hash = "";
361
+ url.search = "";
362
+ return `${url.protocol}//${url.host}${url.pathname}`.toLowerCase();
363
+ }
364
+
365
+ async function sha256B64url(input: string): Promise<string> {
366
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
367
+ return b64urlBytes(new Uint8Array(digest));
368
+ }
369
+
370
+ function b64urlJson(value: unknown): string {
371
+ return b64urlBytes(new TextEncoder().encode(JSON.stringify(value)));
372
+ }
373
+
374
+ function b64urlBytes(bytes: Uint8Array<ArrayBuffer>): string {
375
+ let bin = "";
376
+ for (const b of bytes) bin += String.fromCharCode(b);
377
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
378
+ }
379
+
380
+ function b64urlDecodeBytes(s: string): Uint8Array<ArrayBuffer> {
381
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
382
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
383
+ const bin = atob(padded + pad);
384
+ const bytes = new Uint8Array(bin.length);
385
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
386
+ return bytes;
387
+ }
388
+
389
+ function b64urlDecodeText(s: string): string {
390
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
391
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
392
+ return atob(padded + pad);
393
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * OAuth protocol errors — every AS failure maps to a registered `error`
3
+ * code with the correct HTTP status (RFC 6749 / RFC 6750 / RFC 8707).
4
+ *
5
+ * Scope misses carry the specific missing scope name(s), matching this
6
+ * project's capability-token discipline without touching OKE1007
7
+ * (which stays reserved for undeclared `effects.calls`).
8
+ */
9
+
10
+ /** Registered OAuth error codes emitted by the AS. */
11
+ export type OAuthErrorCode =
12
+ | "invalid_request"
13
+ | "invalid_client"
14
+ | "invalid_grant"
15
+ | "unauthorized_client"
16
+ | "unsupported_grant_type"
17
+ | "invalid_scope"
18
+ | "invalid_target"
19
+ | "invalid_token"
20
+ | "insufficient_scope"
21
+ | "access_denied"
22
+ | "server_error";
23
+
24
+ /** Default HTTP status per OAuth error code. */
25
+ export const OAUTH_ERROR_STATUS: Readonly<Record<OAuthErrorCode, number>> = {
26
+ invalid_request: 400,
27
+ invalid_client: 401,
28
+ invalid_grant: 400,
29
+ unauthorized_client: 400,
30
+ unsupported_grant_type: 400,
31
+ invalid_scope: 400,
32
+ invalid_target: 400,
33
+ invalid_token: 401,
34
+ insufficient_scope: 403,
35
+ access_denied: 400,
36
+ server_error: 500,
37
+ };
38
+
39
+ /**
40
+ * OAuth protocol error — thrown by AS internals, rendered as JSON by the
41
+ * token endpoint (body) or WWW-Authenticate (RS challenges).
42
+ */
43
+ export class OAuthError extends Error {
44
+ /** Registered error code. */
45
+ readonly code: OAuthErrorCode;
46
+ /** Human-readable description safe to return to clients. */
47
+ readonly description: string;
48
+ /** Missing scope names when `code === "insufficient_scope"` / `"invalid_scope"`. */
49
+ readonly missingScopes: readonly string[];
50
+
51
+ /**
52
+ * @param code - Registered error code
53
+ * @param description - Safe diagnostic
54
+ * @param missingScopes - Specific missing scope names (when applicable)
55
+ */
56
+ constructor(code: OAuthErrorCode, description: string, missingScopes: readonly string[] = []) {
57
+ super(`oauth ${code}${description ? `: ${description}` : ""}`);
58
+ this.name = "OAuthError";
59
+ this.code = code;
60
+ this.description = description;
61
+ this.missingScopes = missingScopes;
62
+ }
63
+
64
+ /** HTTP status for this error. */
65
+ get status(): number {
66
+ return OAUTH_ERROR_STATUS[this.code];
67
+ }
68
+ }