okengine 0.4.3 → 0.5.1
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 +3 -1
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +140 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +117 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/fx.mdx +20 -5
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +119 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +12 -12
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +42 -3
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +282 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +253 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/plugins/security-headers.ts +0 -255
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic-link Gate auth method plugin.
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createIdentityStore,
|
|
10
|
+
normalizeEmail,
|
|
11
|
+
type IdentityStore,
|
|
12
|
+
type UserIdentityRow,
|
|
13
|
+
} from "../auth/identity.ts";
|
|
14
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
15
|
+
import {
|
|
16
|
+
createVerificationStore,
|
|
17
|
+
hashChallenge,
|
|
18
|
+
putVerification,
|
|
19
|
+
type VerificationStore,
|
|
20
|
+
} from "../auth/verification.ts";
|
|
21
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
22
|
+
import {
|
|
23
|
+
AuthFailed,
|
|
24
|
+
AuthRateLimited,
|
|
25
|
+
SessionTokensOut,
|
|
26
|
+
bindPublicAuth,
|
|
27
|
+
createMethodRuntime,
|
|
28
|
+
fail,
|
|
29
|
+
flow,
|
|
30
|
+
z,
|
|
31
|
+
type AuthMethodOptions,
|
|
32
|
+
} from "./auth/shared.ts";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** Options for {@link magicLink}. */
|
|
37
|
+
export interface MagicLinkOptions extends AuthMethodOptions {
|
|
38
|
+
/** Challenge TTL (default 10m). */
|
|
39
|
+
readonly ttlMs?: number;
|
|
40
|
+
/** Identity store for email → user. */
|
|
41
|
+
readonly identities?: IdentityStore;
|
|
42
|
+
/** Verification / challenge store. */
|
|
43
|
+
readonly verifications?: VerificationStore;
|
|
44
|
+
/** Return raw token in the request response (test / local). */
|
|
45
|
+
readonly exposeDevToken?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Magic-link request + verify (`oke_verifications` challenge rows).
|
|
50
|
+
*
|
|
51
|
+
* @param opts - TTL / stores / dev token
|
|
52
|
+
*/
|
|
53
|
+
export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
|
|
54
|
+
const runtime = createMethodRuntime(opts);
|
|
55
|
+
const identities = opts.identities ?? createIdentityStore();
|
|
56
|
+
const verifications = opts.verifications ?? createVerificationStore();
|
|
57
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
58
|
+
|
|
59
|
+
const request = flow({
|
|
60
|
+
name: "auth.requestMagicLink",
|
|
61
|
+
unit: "auth",
|
|
62
|
+
plane: "user",
|
|
63
|
+
in: z.object({ email: z.string().min(3) }),
|
|
64
|
+
out: z.object({
|
|
65
|
+
ok: z.literal(true),
|
|
66
|
+
devToken: z.string().optional(),
|
|
67
|
+
}),
|
|
68
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
69
|
+
do: async (input) => {
|
|
70
|
+
const email = normalizeEmail(input.email);
|
|
71
|
+
if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
|
|
72
|
+
const token = `ml_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
73
|
+
const now = runtime.now();
|
|
74
|
+
putVerification(verifications, {
|
|
75
|
+
id: crypto.randomUUID(),
|
|
76
|
+
identifier: `magic:${email}`,
|
|
77
|
+
value: await hashChallenge(token),
|
|
78
|
+
expiresAt: now + ttlMs,
|
|
79
|
+
createdAt: now,
|
|
80
|
+
consumedAt: null,
|
|
81
|
+
attempts: 0,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
ok: true as const,
|
|
85
|
+
...(opts.exposeDevToken ? { devToken: token } : {}),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const verify = flow({
|
|
91
|
+
name: "auth.verifyMagicLink",
|
|
92
|
+
unit: "auth",
|
|
93
|
+
plane: "user",
|
|
94
|
+
in: z.object({ token: z.string().min(1) }),
|
|
95
|
+
out: SessionTokensOut,
|
|
96
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
97
|
+
do: async (input) => {
|
|
98
|
+
const hash = await hashChallenge(input.token);
|
|
99
|
+
const now = runtime.now();
|
|
100
|
+
let row:
|
|
101
|
+
| {
|
|
102
|
+
id: string;
|
|
103
|
+
identifier: string;
|
|
104
|
+
value: string;
|
|
105
|
+
expiresAt: number;
|
|
106
|
+
consumedAt: number | null;
|
|
107
|
+
attempts: number;
|
|
108
|
+
}
|
|
109
|
+
| undefined;
|
|
110
|
+
for (const candidate of verifications.rows.values()) {
|
|
111
|
+
if (!candidate.identifier.startsWith("magic:")) continue;
|
|
112
|
+
if (candidate.consumedAt !== null) continue;
|
|
113
|
+
if (candidate.expiresAt <= now) continue;
|
|
114
|
+
if (candidate.value === hash) {
|
|
115
|
+
row = candidate;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
120
|
+
row.consumedAt = now;
|
|
121
|
+
const email = row.identifier.slice("magic:".length);
|
|
122
|
+
const user = ensureUserByEmail(identities, email, now);
|
|
123
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
124
|
+
id: user.id,
|
|
125
|
+
plane: "user",
|
|
126
|
+
scopes: [],
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
accessToken: issued.accessToken,
|
|
130
|
+
refreshToken: issued.refreshToken,
|
|
131
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
132
|
+
userId: user.id,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return plugin("magicLink", { version: "0.0.1", config: { method: "magic-link" } })
|
|
138
|
+
.needs("auth")
|
|
139
|
+
.binding(bindPublicAuth("/magic-link/request", request, "otp"))
|
|
140
|
+
.binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
|
|
144
|
+
const existingId = store.byEmail.get(email);
|
|
145
|
+
if (existingId) {
|
|
146
|
+
const existing = store.users.get(existingId);
|
|
147
|
+
if (existing) return existing;
|
|
148
|
+
}
|
|
149
|
+
const id = crypto.randomUUID();
|
|
150
|
+
const user: UserIdentityRow = {
|
|
151
|
+
id,
|
|
152
|
+
email,
|
|
153
|
+
name: email.split("@")[0] || "user",
|
|
154
|
+
emailVerified: true,
|
|
155
|
+
status: "active",
|
|
156
|
+
createdAt: now,
|
|
157
|
+
updatedAt: now,
|
|
158
|
+
extra: {},
|
|
159
|
+
};
|
|
160
|
+
store.users.set(id, user);
|
|
161
|
+
store.byEmail.set(email, id);
|
|
162
|
+
return user;
|
|
163
|
+
}
|
|
@@ -18,7 +18,7 @@ beforeEach(() => {
|
|
|
18
18
|
describe("maintenanceMode plugin", () => {
|
|
19
19
|
test("returns 503 with ServiceUnavailable envelope and Retry-After", async () => {
|
|
20
20
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
21
|
-
const app = oke({ name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
|
|
21
|
+
const app = oke({ autoBoot: false, name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
|
|
22
22
|
|
|
23
23
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
24
24
|
|
|
@@ -36,7 +36,9 @@ describe("maintenanceMode plugin", () => {
|
|
|
36
36
|
|
|
37
37
|
test("enabled: false passes traffic through", async () => {
|
|
38
38
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
39
|
-
const app = oke({ name: "maint-off" }).plug(
|
|
39
|
+
const app = oke({ autoBoot: false, name: "maint-off" }).plug(
|
|
40
|
+
maintenanceMode({ enabled: false }),
|
|
41
|
+
);
|
|
40
42
|
|
|
41
43
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
42
44
|
|
|
@@ -46,7 +48,9 @@ describe("maintenanceMode plugin", () => {
|
|
|
46
48
|
test("allowPaths prefixes keep serving", async () => {
|
|
47
49
|
on(http.get("/health"), flow({ name: "health.get", do: () => ({ up: true }) }));
|
|
48
50
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
49
|
-
const app = oke({ name: "maint-allow" }).plug(
|
|
51
|
+
const app = oke({ autoBoot: false, name: "maint-allow" }).plug(
|
|
52
|
+
maintenanceMode({ allowPaths: ["/health"] }),
|
|
53
|
+
);
|
|
50
54
|
|
|
51
55
|
const health = await app.fetch(new Request("http://localhost/health"));
|
|
52
56
|
expect(health.status).toBe(200);
|
|
@@ -57,7 +61,7 @@ describe("maintenanceMode plugin", () => {
|
|
|
57
61
|
|
|
58
62
|
test("bypass header lets operators through (any non-empty value)", async () => {
|
|
59
63
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
60
|
-
const app = oke({ name: "maint-bypass" }).plug(
|
|
64
|
+
const app = oke({ autoBoot: false, name: "maint-bypass" }).plug(
|
|
61
65
|
maintenanceMode({ bypassHeader: "x-ops-token" }),
|
|
62
66
|
);
|
|
63
67
|
|
|
@@ -72,7 +76,7 @@ describe("maintenanceMode plugin", () => {
|
|
|
72
76
|
|
|
73
77
|
test("the 503 still flows through onResponse (other plugins stamp it)", async () => {
|
|
74
78
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
75
|
-
const app = oke({ name: "maint-stamp" }).plug(maintenanceMode());
|
|
79
|
+
const app = oke({ autoBoot: false, name: "maint-stamp" }).plug(maintenanceMode());
|
|
76
80
|
app.hook("onResponse", (ctx) => {
|
|
77
81
|
if (!ctx.response) return;
|
|
78
82
|
const headers = new Headers(ctx.response.headers);
|
|
@@ -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
|
+
}
|