okengine 0.5.0 → 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 +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/plugins/auth-methods.security.test.ts +762 -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/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +11 -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/security-headers.ts +0 -255
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* v1 verify accepts `{ userId, code }` after password sign-in when 2FA is enabled.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { constantTimeEqual } from "../auth/constant-time.ts";
|
|
7
8
|
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
8
9
|
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
9
10
|
import {
|
|
@@ -163,11 +164,14 @@ export async function verifyTotp(
|
|
|
163
164
|
if (!/^\d{6}$/.test(code)) return false;
|
|
164
165
|
const key = base32Decode(secretBase32);
|
|
165
166
|
const counter = Math.floor(nowSec / 30);
|
|
167
|
+
// Compare every window with constant-time equality — never short-circuit on
|
|
168
|
+
// the first match (avoids leaking which step matched via `===` timing).
|
|
169
|
+
let ok = 0;
|
|
166
170
|
for (let w = -1; w <= 1; w++) {
|
|
167
171
|
const otp = await hotp(key, counter + w);
|
|
168
|
-
|
|
172
|
+
ok |= constantTimeEqual(otp, code) ? 1 : 0;
|
|
169
173
|
}
|
|
170
|
-
return
|
|
174
|
+
return ok === 1;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
async function hotp(key: Uint8Array, counter: number): Promise<string> {
|