okengine 0.5.0 → 0.6.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 (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -16,9 +16,14 @@ export {
16
16
  } from "./config-source.ts";
17
17
  export { cors, type CorsOptions } from "./cors.ts";
18
18
  export { csrf, type CsrfOptions } from "./csrf.ts";
19
- export { emailOtp, type EmailOtpOptions } from "./email-otp.ts";
19
+ export { emailOtp, emailOtpCatalog, emailOtpTemplate, type EmailOtpOptions } from "./email-otp.ts";
20
20
  export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
21
- export { magicLink, type MagicLinkOptions } from "./magic-link.ts";
21
+ export {
22
+ magicLink,
23
+ magicLinkCatalog,
24
+ magicLinkTemplate,
25
+ type MagicLinkOptions,
26
+ } from "./magic-link.ts";
22
27
  export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
23
28
  export {
24
29
  createPasskeyStore,
@@ -27,6 +32,14 @@ export {
27
32
  type PasskeyOptions,
28
33
  type PasskeyStore,
29
34
  } from "./passkey.ts";
35
+ export {
36
+ b64urlDecode,
37
+ b64urlEncode,
38
+ buildAuthenticatorData,
39
+ signWebAuthnAssertion,
40
+ verifyWebAuthnCeremony,
41
+ type WebAuthnVerifyOptions,
42
+ } from "./passkey-webauthn.ts";
30
43
  export {
31
44
  createPhoneStore,
32
45
  phoneNumber,
@@ -35,11 +48,11 @@ export {
35
48
  } from "./phone-number.ts";
36
49
  export {
37
50
  defaultCspDirectives,
38
- securityHeaders,
51
+ headers,
39
52
  type CspOptions,
53
+ type HeadersOptions,
40
54
  type HstsOptions,
41
- type SecurityHeadersOptions,
42
- } from "./security-headers.ts";
55
+ } from "./headers.ts";
43
56
  export {
44
57
  createTwoFactorStore,
45
58
  twoFactor,
@@ -49,9 +62,18 @@ export {
49
62
  type TwoFactorStore,
50
63
  } from "./two-factor.ts";
51
64
  export {
65
+ assertUsernamePolicy,
52
66
  createUsernameStore,
67
+ DEFAULT_RESERVED_USERNAMES,
68
+ DEFAULT_USERNAME_ALLOWED_CHARS,
69
+ DEFAULT_USERNAME_MAX_LENGTH,
70
+ DEFAULT_USERNAME_MIN_LENGTH,
71
+ resolveUsernamePolicy,
53
72
  username,
73
+ UsernamePolicyError,
74
+ type ResolvedUsernamePolicy,
54
75
  type UsernamePluginOptions,
76
+ type UsernamePolicyOptions,
55
77
  type UsernameRow,
56
78
  type UsernameStore,
57
79
  } from "./username.ts";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Magic-link Gate auth method plugin.
3
3
  *
4
- * Delivery via Channel is optional in v1 when {@link MagicLinkOptions.exposeDevToken}
5
- * is set, the request response includes `devToken` for tests / local DX.
4
+ * Delivers the one-time token via Channel (`fx.send` + `auth-magic-link`
5
+ * template). {@link MagicLinkOptions.exposeDevToken} remains available for
6
+ * local DX without Mailpit / SMTP.
6
7
  */
7
8
 
8
9
  import {
@@ -18,6 +19,7 @@ import {
18
19
  putVerification,
19
20
  type VerificationStore,
20
21
  } from "../auth/verification.ts";
22
+ import { channel } from "../elements/channel.ts";
21
23
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
22
24
  import {
23
25
  AuthFailed,
@@ -32,6 +34,35 @@ import {
32
34
  } from "./auth/shared.ts";
33
35
 
34
36
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
37
+ const DEFAULT_FROM = "OKE <no-reply@oke.local>";
38
+ const DEFAULT_BASE_URL = "http://127.0.0.1:6530";
39
+
40
+ /** Channel template for magic-link delivery. */
41
+ export const magicLinkTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-magic-link", {
42
+ description: "Magic-link sign-in email",
43
+ schema: z.object({
44
+ email: z.string(),
45
+ token: z.string(),
46
+ link: z.string(),
47
+ }),
48
+ locales: ["en", "ar"],
49
+ });
50
+
51
+ /** Default EN/AR bodies for {@link magicLinkTemplate}. */
52
+ export const magicLinkCatalog = {
53
+ "auth-magic-link": {
54
+ en: {
55
+ subject: "Your sign-in link",
56
+ text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
57
+ html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
58
+ },
59
+ ar: {
60
+ subject: "رابط تسجيل الدخول",
61
+ text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
62
+ html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
63
+ },
64
+ },
65
+ } as const;
35
66
 
36
67
  /** Options for {@link magicLink}. */
37
68
  export interface MagicLinkOptions extends AuthMethodOptions {
@@ -43,6 +74,13 @@ export interface MagicLinkOptions extends AuthMethodOptions {
43
74
  readonly verifications?: VerificationStore;
44
75
  /** Return raw token in the request response (test / local). */
45
76
  readonly exposeDevToken?: boolean;
77
+ /**
78
+ * App origin used to build the magic link (default `OKE_APP_URL` or
79
+ * `http://127.0.0.1:6530`).
80
+ */
81
+ readonly baseUrl?: string;
82
+ /** Override the template `from` address. */
83
+ readonly from?: string;
46
84
  }
47
85
 
48
86
  /**
@@ -55,6 +93,19 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
55
93
  const identities = opts.identities ?? createIdentityStore();
56
94
  const verifications = opts.verifications ?? createVerificationStore();
57
95
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
96
+ const baseUrl = (opts.baseUrl ?? process.env.OKE_APP_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
97
+ const tmpl =
98
+ opts.from !== undefined
99
+ ? channel.email({ from: opts.from }).template("auth-magic-link", {
100
+ description: "Magic-link sign-in email",
101
+ schema: z.object({
102
+ email: z.string(),
103
+ token: z.string(),
104
+ link: z.string(),
105
+ }),
106
+ locales: ["en", "ar"],
107
+ })
108
+ : magicLinkTemplate;
58
109
 
59
110
  const request = flow({
60
111
  name: "auth.requestMagicLink",
@@ -66,7 +117,8 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
66
117
  devToken: z.string().optional(),
67
118
  }),
68
119
  errors: { AuthFailed, AuthRateLimited },
69
- do: async (input) => {
120
+ effects: { sends: ["auth-magic-link"] },
121
+ do: async (input, fx) => {
70
122
  const email = normalizeEmail(input.email);
71
123
  if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
72
124
  const token = `ml_${crypto.randomUUID().replace(/-/g, "")}`;
@@ -80,6 +132,11 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
80
132
  consumedAt: null,
81
133
  attempts: 0,
82
134
  });
135
+ const link = `${baseUrl}/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
136
+ await fx.send(tmpl, {
137
+ to: email,
138
+ data: { email, token, link },
139
+ });
83
140
  return {
84
141
  ok: true as const,
85
142
  ...(opts.exposeDevToken ? { devToken: token } : {}),
@@ -136,6 +193,9 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
136
193
 
137
194
  return plugin("magicLink", { version: "0.0.1", config: { method: "magic-link" } })
138
195
  .needs("auth")
196
+ .needs("channel")
197
+ .channelTemplate(tmpl)
198
+ .channelCatalog(magicLinkCatalog)
139
199
  .binding(bindPublicAuth("/magic-link/request", request, "otp"))
140
200
  .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
141
201
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Minimal WebAuthn assertion / registration verifier (ES256 / P-256).
3
+ *
4
+ * Validates clientDataJSON (type · challenge · origin), authenticatorData
5
+ * (rpId hash · user-presence), and ECDSA signature over
6
+ * `authenticatorData || SHA-256(clientDataJSON)` against a stored SPKI public key.
7
+ */
8
+
9
+ import { constantTimeEqual } from "../auth/constant-time.ts";
10
+
11
+ /** Result of a failed WebAuthn verify. */
12
+ export type WebAuthnVerifyFailure = {
13
+ readonly ok: false;
14
+ readonly reason: string;
15
+ };
16
+
17
+ /** Successful assertion verify (with updated sign counter). */
18
+ export type WebAuthnVerifySuccess = {
19
+ readonly ok: true;
20
+ readonly signCount: number;
21
+ };
22
+
23
+ /** Options for {@link verifyWebAuthnCeremony}. */
24
+ export interface WebAuthnVerifyOptions {
25
+ readonly expectedType: "webauthn.create" | "webauthn.get";
26
+ readonly expectedChallenge: string;
27
+ readonly expectedOrigins: readonly string[];
28
+ readonly rpId: string;
29
+ /** Base64url SPKI (SubjectPublicKeyInfo) of the credential public key. */
30
+ readonly publicKeySpkiB64url: string;
31
+ /** Base64url clientDataJSON bytes. */
32
+ readonly clientDataJSON: string;
33
+ /** Base64url authenticatorData bytes. */
34
+ readonly authenticatorData: string;
35
+ /** Base64url ECDSA P-1363 signature. */
36
+ readonly signature: string;
37
+ /** Previous stored sign counter (authenticate only). */
38
+ readonly previousSignCount?: number;
39
+ }
40
+
41
+ /**
42
+ * Verify a WebAuthn create/get ceremony payload.
43
+ *
44
+ * @param opts - Expected RP values + assertion fields
45
+ */
46
+ export async function verifyWebAuthnCeremony(
47
+ opts: WebAuthnVerifyOptions,
48
+ ): Promise<WebAuthnVerifySuccess | WebAuthnVerifyFailure> {
49
+ let clientDataBytes: Uint8Array;
50
+ let authData: Uint8Array;
51
+ let signature: Uint8Array;
52
+ try {
53
+ clientDataBytes = b64urlDecode(opts.clientDataJSON);
54
+ authData = b64urlDecode(opts.authenticatorData);
55
+ signature = b64urlDecode(opts.signature);
56
+ } catch {
57
+ return { ok: false, reason: "invalid_credentials" };
58
+ }
59
+
60
+ if (authData.length < 37) return { ok: false, reason: "invalid_credentials" };
61
+
62
+ let clientData: { type?: unknown; challenge?: unknown; origin?: unknown };
63
+ try {
64
+ clientData = JSON.parse(new TextDecoder().decode(clientDataBytes)) as typeof clientData;
65
+ } catch {
66
+ return { ok: false, reason: "invalid_credentials" };
67
+ }
68
+
69
+ if (typeof clientData.type !== "string" || clientData.type !== opts.expectedType) {
70
+ return { ok: false, reason: "invalid_credentials" };
71
+ }
72
+ if (typeof clientData.challenge !== "string") {
73
+ return { ok: false, reason: "invalid_credentials" };
74
+ }
75
+ if (!constantTimeEqual(clientData.challenge, opts.expectedChallenge)) {
76
+ return { ok: false, reason: "invalid_credentials" };
77
+ }
78
+ if (typeof clientData.origin !== "string") {
79
+ return { ok: false, reason: "invalid_credentials" };
80
+ }
81
+ if (!opts.expectedOrigins.includes(clientData.origin)) {
82
+ return { ok: false, reason: "invalid_origin" };
83
+ }
84
+
85
+ const rpHash = new Uint8Array(
86
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(opts.rpId)),
87
+ );
88
+ for (let i = 0; i < 32; i++) {
89
+ if (authData[i] !== rpHash[i]) return { ok: false, reason: "invalid_credentials" };
90
+ }
91
+
92
+ const flags = authData[32]!;
93
+ // User Present (bit 0) required.
94
+ if ((flags & 0x01) === 0) return { ok: false, reason: "invalid_credentials" };
95
+
96
+ const signCount =
97
+ ((authData[33]! << 24) | (authData[34]! << 16) | (authData[35]! << 8) | authData[36]!) >>> 0;
98
+
99
+ if (opts.previousSignCount !== undefined) {
100
+ // Spec: if both non-zero, authenticator count must strictly increase.
101
+ if (opts.previousSignCount > 0 && signCount > 0 && signCount <= opts.previousSignCount) {
102
+ return { ok: false, reason: "invalid_credentials" };
103
+ }
104
+ }
105
+
106
+ const clientDataHash = new Uint8Array(
107
+ await crypto.subtle.digest("SHA-256", asBufferSource(clientDataBytes)),
108
+ );
109
+ const signed = new Uint8Array(authData.length + clientDataHash.length);
110
+ signed.set(authData, 0);
111
+ signed.set(clientDataHash, authData.length);
112
+
113
+ let key: CryptoKey;
114
+ try {
115
+ key = await crypto.subtle.importKey(
116
+ "spki",
117
+ asBufferSource(b64urlDecode(opts.publicKeySpkiB64url)),
118
+ { name: "ECDSA", namedCurve: "P-256" },
119
+ false,
120
+ ["verify"],
121
+ );
122
+ } catch {
123
+ return { ok: false, reason: "invalid_credentials" };
124
+ }
125
+
126
+ const valid = await crypto.subtle.verify(
127
+ { name: "ECDSA", hash: "SHA-256" },
128
+ key,
129
+ asBufferSource(signature),
130
+ asBufferSource(signed),
131
+ );
132
+ if (!valid) return { ok: false, reason: "invalid_credentials" };
133
+
134
+ return { ok: true, signCount };
135
+ }
136
+
137
+ /**
138
+ * Encode bytes as unpadded base64url.
139
+ *
140
+ * @param bytes - Raw bytes
141
+ */
142
+ export function b64urlEncode(bytes: Uint8Array): string {
143
+ let s = "";
144
+ for (const b of bytes) s += String.fromCharCode(b);
145
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
146
+ }
147
+
148
+ /**
149
+ * Decode unpadded base64url to bytes.
150
+ *
151
+ * @param input - Base64url string
152
+ */
153
+ export function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
154
+ const pad = "=".repeat((4 - (input.length % 4)) % 4);
155
+ const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
156
+ const bin = atob(b64);
157
+ const out = new Uint8Array(bin.length);
158
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Build minimal authenticatorData (rpIdHash · flags · signCount).
164
+ *
165
+ * @param rpId - Relying party id
166
+ * @param signCount - Counter
167
+ * @param flags - Flag byte (default UP=0x01)
168
+ */
169
+ export async function buildAuthenticatorData(
170
+ rpId: string,
171
+ signCount: number,
172
+ flags = 0x01,
173
+ ): Promise<Uint8Array> {
174
+ const rpHash = new Uint8Array(
175
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)),
176
+ );
177
+ const out = new Uint8Array(37);
178
+ out.set(rpHash, 0);
179
+ out[32] = flags;
180
+ out[33] = (signCount >>> 24) & 0xff;
181
+ out[34] = (signCount >>> 16) & 0xff;
182
+ out[35] = (signCount >>> 8) & 0xff;
183
+ out[36] = signCount & 0xff;
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Sign `authenticatorData || SHA-256(clientDataJSON)` with an ECDSA P-256 key.
189
+ *
190
+ * @param privateKey - Signer
191
+ * @param authenticatorData - Auth data bytes
192
+ * @param clientDataJSON - Raw clientDataJSON bytes
193
+ */
194
+ export async function signWebAuthnAssertion(
195
+ privateKey: CryptoKey,
196
+ authenticatorData: Uint8Array,
197
+ clientDataJSON: Uint8Array,
198
+ ): Promise<Uint8Array> {
199
+ const clientDataHash = new Uint8Array(
200
+ await crypto.subtle.digest("SHA-256", asBufferSource(clientDataJSON)),
201
+ );
202
+ const signed = new Uint8Array(authenticatorData.length + clientDataHash.length);
203
+ signed.set(authenticatorData, 0);
204
+ signed.set(clientDataHash, authenticatorData.length);
205
+ return new Uint8Array(
206
+ await crypto.subtle.sign(
207
+ { name: "ECDSA", hash: "SHA-256" },
208
+ privateKey,
209
+ asBufferSource(signed),
210
+ ),
211
+ );
212
+ }
213
+
214
+ /** Fresh buffer for Web Crypto `BufferSource` typing. */
215
+ function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
216
+ return new Uint8Array(bytes);
217
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Passkey (WebAuthn-shaped) Gate auth method plugin — simplified v1 ceremony.
2
+ * Passkey (WebAuthn) Gate auth method plugin.
3
3
  *
4
- * Registration / authentication accept attestation-like payloads for tests
5
- * without a full WebAuthn library. Production should replace the ceremony
6
- * with a standards-compliant verifier.
4
+ * Registration and authentication verify clientDataJSON origin + challenge,
5
+ * authenticatorData rpId hash, and ECDSA P-256 signature against the stored
6
+ * SPKI public key. Presence-only authenticate is rejected.
7
7
  */
8
8
 
9
+ import { constantTimeEqual } from "../auth/constant-time.ts";
9
10
  import {
10
11
  createVerificationStore,
11
12
  hashChallenge,
@@ -26,11 +27,13 @@ import {
26
27
  z,
27
28
  type AuthMethodOptions,
28
29
  } from "./auth/shared.ts";
30
+ import { verifyWebAuthnCeremony } from "./passkey-webauthn.ts";
29
31
 
30
- /** Stored passkey credential (simplified). */
32
+ /** Stored passkey credential. */
31
33
  export interface PasskeyCredential {
32
34
  readonly credentialId: string;
33
35
  readonly userId: string;
36
+ /** Base64url SPKI public key (ECDSA P-256). */
34
37
  readonly publicKey: string;
35
38
  counter: number;
36
39
  readonly createdAt: number;
@@ -55,18 +58,34 @@ export interface PasskeyOptions extends AuthMethodOptions {
55
58
  readonly challenges?: VerificationStore;
56
59
  /** Relying party id (default `localhost`). */
57
60
  readonly rpId?: string;
61
+ /**
62
+ * Allowed `clientDataJSON.origin` values.
63
+ * Default: `http://localhost` and `https://localhost`.
64
+ */
65
+ readonly origins?: readonly string[];
58
66
  }
59
67
 
68
+ const CeremonyIn = z.object({
69
+ credentialId: z.string().min(1),
70
+ publicKey: z.string().min(1).optional(),
71
+ userId: z.string().min(1).optional(),
72
+ challenge: z.string().min(1),
73
+ clientDataJSON: z.string().min(1),
74
+ authenticatorData: z.string().min(1),
75
+ signature: z.string().min(1),
76
+ });
77
+
60
78
  /**
61
- * Passkey register / authenticate options + simplified ceremony (`oke_passkeys`).
79
+ * Passkey register / authenticate with cryptographic WebAuthn verify (`oke_passkeys`).
62
80
  *
63
- * @param opts - Stores / RP id
81
+ * @param opts - Stores / RP id / allowed origins
64
82
  */
65
83
  export function passkey(opts: PasskeyOptions = {}): PluginDef {
66
84
  const runtime = createMethodRuntime(opts);
67
85
  const passkeys = opts.passkeys ?? createPasskeyStore();
68
86
  const challenges = opts.challenges ?? createVerificationStore();
69
87
  const rpId = opts.rpId ?? "localhost";
88
+ const origins = opts.origins ?? ["http://localhost", "https://localhost"];
70
89
 
71
90
  const registerOptions = flow({
72
91
  name: "auth.passkeyRegisterOptions",
@@ -100,11 +119,9 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
100
119
  name: "auth.passkeyRegister",
101
120
  unit: "auth",
102
121
  plane: "user",
103
- in: z.object({
104
- credentialId: z.string().min(1),
122
+ in: CeremonyIn.extend({
105
123
  publicKey: z.string().min(1),
106
124
  userId: z.string().min(1),
107
- challenge: z.string().optional(),
108
125
  }),
109
126
  out: z.object({ ok: z.literal(true) }),
110
127
  errors: { AuthFailed },
@@ -113,20 +130,28 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
113
130
  if (!sessionUser || sessionUser !== input.userId) {
114
131
  return fail("AuthFailed", { reason: "unauthenticated" });
115
132
  }
116
- if (input.challenge) {
117
- const hash = await hashChallenge(input.challenge);
118
- const now = runtime.now();
119
- let found = false;
120
- for (const row of challenges.rows.values()) {
121
- if (row.identifier !== `passkey-reg:${input.userId}`) continue;
122
- if (row.consumedAt !== null || row.expiresAt <= now) continue;
123
- if (row.value === hash) {
124
- row.consumedAt = now;
125
- found = true;
126
- break;
127
- }
128
- }
129
- if (!found) return fail("AuthFailed", { reason: "invalid_credentials" });
133
+ const consumed = await consumeChallenge(
134
+ challenges,
135
+ `passkey-reg:${input.userId}`,
136
+ input.challenge,
137
+ runtime.now(),
138
+ );
139
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
140
+
141
+ const verified = await verifyWebAuthnCeremony({
142
+ expectedType: "webauthn.create",
143
+ expectedChallenge: input.challenge,
144
+ expectedOrigins: origins,
145
+ rpId,
146
+ publicKeySpkiB64url: input.publicKey,
147
+ clientDataJSON: input.clientDataJSON,
148
+ authenticatorData: input.authenticatorData,
149
+ signature: input.signature,
150
+ });
151
+ if (!verified.ok) {
152
+ return fail("AuthFailed", {
153
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
154
+ });
130
155
  }
131
156
  if (passkeys.byCredentialId.has(input.credentialId)) {
132
157
  return fail("AuthFailed", { reason: "invalid_credentials" });
@@ -135,7 +160,7 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
135
160
  credentialId: input.credentialId,
136
161
  userId: input.userId,
137
162
  publicKey: input.publicKey,
138
- counter: 0,
163
+ counter: verified.signCount,
139
164
  createdAt: runtime.now(),
140
165
  };
141
166
  passkeys.byCredentialId.set(cred.credentialId, cred);
@@ -170,7 +195,6 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
170
195
  consumedAt: null,
171
196
  attempts: 0,
172
197
  });
173
- // Simplified: empty allow list when no email mapping (caller supplies credentialId).
174
198
  return { challenge, rpId, allowCredentials: [] as string[] };
175
199
  },
176
200
  });
@@ -179,19 +203,44 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
179
203
  name: "auth.passkeyAuthenticate",
180
204
  unit: "auth",
181
205
  plane: "user",
182
- in: z.object({
183
- credentialId: z.string().min(1),
184
- userId: z.string().min(1),
206
+ in: CeremonyIn.extend({
207
+ challenge: z.string().min(1),
208
+ /** Challenge bucket key from authenticate options (default `anonymous`). */
209
+ email: z.string().optional(),
185
210
  }),
186
211
  out: SessionTokensOut,
187
212
  errors: { AuthFailed, AuthRateLimited },
188
213
  do: async (input) => {
189
- // Simplified v1: presence of stored credential + matching userId issues a session.
190
214
  const cred = passkeys.byCredentialId.get(input.credentialId);
191
- if (!cred || cred.userId !== input.userId) {
192
- return fail("AuthFailed", { reason: "invalid_credentials" });
215
+ if (!cred) return fail("AuthFailed", { reason: "invalid_credentials" });
216
+
217
+ const bucket = input.email?.trim().toLowerCase() || "anonymous";
218
+ const consumed = await consumeChallenge(
219
+ challenges,
220
+ `passkey-auth:${bucket}`,
221
+ input.challenge,
222
+ runtime.now(),
223
+ );
224
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
225
+
226
+ const verified = await verifyWebAuthnCeremony({
227
+ expectedType: "webauthn.get",
228
+ expectedChallenge: input.challenge,
229
+ expectedOrigins: origins,
230
+ rpId,
231
+ publicKeySpkiB64url: cred.publicKey,
232
+ clientDataJSON: input.clientDataJSON,
233
+ authenticatorData: input.authenticatorData,
234
+ signature: input.signature,
235
+ previousSignCount: cred.counter,
236
+ });
237
+ if (!verified.ok) {
238
+ return fail("AuthFailed", {
239
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
240
+ });
193
241
  }
194
- cred.counter += 1;
242
+ cred.counter = verified.signCount;
243
+
195
244
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
196
245
  id: cred.userId,
197
246
  plane: "user",
@@ -214,3 +263,20 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
214
263
  .binding(bindPublicAuth("/passkey/authenticate/options", authenticateOptions, "otp"))
215
264
  .binding(bindPublicAuth("/passkey/authenticate", authenticate, "otp"));
216
265
  }
266
+
267
+ async function consumeChallenge(
268
+ store: VerificationStore,
269
+ identifier: string,
270
+ challenge: string,
271
+ now: number,
272
+ ): Promise<boolean> {
273
+ const hash = await hashChallenge(challenge);
274
+ for (const row of store.rows.values()) {
275
+ if (row.identifier !== identifier) continue;
276
+ if (row.consumedAt !== null || row.expiresAt <= now) continue;
277
+ if (!constantTimeEqual(row.value, hash)) continue;
278
+ row.consumedAt = now;
279
+ return true;
280
+ }
281
+ return false;
282
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared response-header mutation for HTTP middleware plugins.
3
+ * Web `Headers` are immutable-in-place on a built `Response` — rebuild instead.
4
+ */
5
+
6
+ /**
7
+ * Rebuild a response with headers mutated by `fn`.
8
+ *
9
+ * @param response - Original response
10
+ * @param fn - Header mutation
11
+ */
12
+ export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
13
+ const headers = new Headers(response.headers);
14
+ fn(headers);
15
+ return new Response(response.body, {
16
+ status: response.status,
17
+ statusText: response.statusText,
18
+ headers,
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Set a header only when absent (or when `override` is on).
24
+ *
25
+ * @param headers - Mutable headers
26
+ * @param name - Header name
27
+ * @param value - Header value
28
+ * @param override - Replace an app-set value
29
+ */
30
+ export function setUnlessPresent(
31
+ headers: Headers,
32
+ name: string,
33
+ value: string,
34
+ override: boolean,
35
+ ): void {
36
+ if (!override && headers.has(name)) return;
37
+ headers.set(name, value);
38
+ }
39
+
40
+ /**
41
+ * Append a token to `Vary` without duplicating it.
42
+ *
43
+ * @param headers - Mutable headers
44
+ * @param token - Vary token (e.g. `"origin"`)
45
+ */
46
+ export function appendVary(headers: Headers, token: string): void {
47
+ const vary = headers.get("vary");
48
+ if (vary === null) {
49
+ headers.set("vary", token);
50
+ return;
51
+ }
52
+ const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
53
+ if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
54
+ }