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.
- package/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/method-context.ts +12 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +764 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +27 -5
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
- package/src/plugins/security-headers.ts +0 -255
package/src/plugins/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
51
|
+
headers,
|
|
39
52
|
type CspOptions,
|
|
53
|
+
type HeadersOptions,
|
|
40
54
|
type HstsOptions,
|
|
41
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/plugins/passkey.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Passkey (WebAuthn
|
|
2
|
+
* Passkey (WebAuthn) Gate auth method plugin.
|
|
3
3
|
*
|
|
4
|
-
* Registration
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
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:
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
192
|
-
|
|
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
|
|
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
|
+
}
|