@smartcrab/browser 0.1.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/src/pkce.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { base64UrlEncodeBytes } from "./base64url.js";
2
+
3
+ /**
4
+ * PKCE (RFC 7636) and anti-replay value generation for the SPA flow
5
+ * (design.md §22.3, §32.1: Authorization Code + PKCE S256, state/nonce 必須).
6
+ *
7
+ * Uses the bare `crypto` WebCrypto global only (`crypto.getRandomValues`,
8
+ * `crypto.subtle.digest`) — never `globalThis.crypto`, never Node builtins —
9
+ * so the module works identically in browsers and Workers-like runtimes
10
+ * (IMPL_NOTES §5).
11
+ */
12
+
13
+ export interface PkcePair {
14
+ /** RFC 7636 §4.1 verifier: 43 chars from 32 random bytes, unreserved charset. */
15
+ readonly verifier: string;
16
+ /** RFC 7636 §4.2 S256 challenge: base64url(SHA-256(verifier)), 43 chars. */
17
+ readonly challenge: string;
18
+ readonly method: "S256";
19
+ }
20
+
21
+ const randomBase64Url = (byteLength: number): string => {
22
+ const bytes = new Uint8Array(byteLength);
23
+ crypto.getRandomValues(bytes);
24
+ return base64UrlEncodeBytes(bytes);
25
+ };
26
+
27
+ /**
28
+ * Transaction `state` (design.md §16.1: the server stores only a MAC of it).
29
+ * 256 bits of entropy, base64url — opaque to the server.
30
+ */
31
+ export const generateState = (): string => randomBase64Url(32);
32
+
33
+ /** OIDC `nonce` for ID token replay protection (design.md §32.1). */
34
+ export const generateNonce = (): string => randomBase64Url(32);
35
+
36
+ /** Generates a verifier/challenge pair; `plain` is never offered (design.md §15.3). */
37
+ export const generatePkcePair = async (): Promise<PkcePair> => {
38
+ // 32 bytes → 43 base64url chars: inside the RFC 7636 §4.1 43–128 window and
39
+ // matches the contracts-public `codeVerifierSchema` charset exactly.
40
+ const verifier = randomBase64Url(32);
41
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
42
+ return { verifier, challenge: base64UrlEncodeBytes(new Uint8Array(digest)), method: "S256" };
43
+ };
@@ -0,0 +1,295 @@
1
+ import type {
2
+ PasskeyAuthenticationCredential,
3
+ PasskeyAuthenticationOptionsResponse,
4
+ PasskeyRegistrationCredential,
5
+ PasskeyRegistrationOptionsResponse,
6
+ } from "@smartcrab/contracts-public";
7
+ import { err, ok } from "@smartcrab/contracts-public";
8
+ import type { Outcome } from "@smartcrab/contracts-public";
9
+ import { base64UrlDecodeToBytes, bufferToBase64Url } from "./base64url.js";
10
+ import type { WebAuthnFailureError, WebAuthnFailureReason } from "./errors.js";
11
+
12
+ /**
13
+ * WebAuthn ceremony boundary (design.md §17, §22.3).
14
+ *
15
+ * `@simplewebauthn/browser` is deliberately NOT a dependency of this SDK: the
16
+ * surface we need is two `navigator.credentials` calls plus base64url
17
+ * (de)serialization, so the SDK ships dependency-free behind this minimal
18
+ * port. Tests and non-standard runtimes inject their own implementation;
19
+ * production uses `navigatorWebAuthnPort`.
20
+ *
21
+ * Exception boundaries are promise rejection handlers, never `try`/`catch`
22
+ * statements: `scripts/check-backend-error-style.ts` (design.md §5.2) scans
23
+ * this package and rejects direct try-catch.
24
+ */
25
+
26
+ export interface WebAuthnBrowserPort {
27
+ /** Runtime capability check (design.md §21.2: UI hides passkeys when false). */
28
+ isAvailable(): boolean;
29
+ /** `startRegistration`-style ceremony: JSON options in, JSON credential out. */
30
+ create(
31
+ options: PasskeyRegistrationOptionsResponse,
32
+ ): Promise<Outcome<PasskeyRegistrationCredential, WebAuthnFailureError>>;
33
+ /** `startAuthentication`-style ceremony: JSON options in, JSON assertion out. */
34
+ get(
35
+ options: PasskeyAuthenticationOptionsResponse,
36
+ ): Promise<Outcome<PasskeyAuthenticationCredential, WebAuthnFailureError>>;
37
+ }
38
+
39
+ const failure = (
40
+ reason: WebAuthnFailureReason,
41
+ message: string,
42
+ ): Outcome<never, WebAuthnFailureError> => err({ type: "webauthn_error", reason, message });
43
+
44
+ const NOT_SUPPORTED: Outcome<never, WebAuthnFailureError> = err({
45
+ type: "webauthn_error",
46
+ reason: "not_supported",
47
+ message: "WebAuthn (PublicKeyCredential) is not available in this environment",
48
+ });
49
+
50
+ /** Normalizes DOMException names from the authenticator into the public error reasons. */
51
+ const mapCeremonyException = (cause: unknown): WebAuthnFailureError => {
52
+ if (cause instanceof DOMException) {
53
+ switch (cause.name) {
54
+ case "NotAllowedError":
55
+ case "AbortError":
56
+ // User dismissal or ceremony timeout (WebAuthn Level 3 §6.1).
57
+ return { type: "webauthn_error", reason: "cancelled", message: cause.message };
58
+ case "NotSupportedError":
59
+ return { type: "webauthn_error", reason: "not_supported", message: cause.message };
60
+ default:
61
+ return { type: "webauthn_error", reason: "failed", message: cause.message };
62
+ }
63
+ }
64
+ const message = cause instanceof Error ? cause.message : "Unknown WebAuthn failure";
65
+ return { type: "webauthn_error", reason: "failed", message };
66
+ };
67
+
68
+ // The WebAuthn standard transport enum (WebAuthn Level 3 §5.8.4); matches the
69
+ // contracts-public transport union member-for-member.
70
+ type Transport = "ble" | "hybrid" | "internal" | "nfc" | "usb";
71
+
72
+ const AUTHENTICATOR_TRANSPORTS: readonly string[] = ["ble", "hybrid", "internal", "nfc", "usb"];
73
+
74
+ const isTransport = (value: string): value is Transport => AUTHENTICATOR_TRANSPORTS.includes(value);
75
+
76
+ /** `getTransports()` is Level 3; older engines lack it, hence the feature detection. */
77
+ const readTransports = (candidate: { getTransports?: unknown }): Transport[] | undefined => {
78
+ if (typeof candidate.getTransports !== "function") return undefined;
79
+ const raw: unknown = candidate.getTransports();
80
+ if (!Array.isArray(raw)) return undefined;
81
+ const transports = raw
82
+ .filter((entry): entry is string => typeof entry === "string")
83
+ .filter(isTransport);
84
+ return transports.length > 0 ? transports : undefined;
85
+ };
86
+
87
+ const isPublicKeyCredential = (credential: Credential): credential is PublicKeyCredential =>
88
+ credential.type === "public-key" && "rawId" in credential && "response" in credential;
89
+
90
+ const isAttestationResponse = (
91
+ response: AuthenticatorResponse,
92
+ ): response is AuthenticatorAttestationResponse => "attestationObject" in response;
93
+
94
+ const isAssertionResponse = (
95
+ response: AuthenticatorResponse,
96
+ ): response is AuthenticatorAssertionResponse =>
97
+ "authenticatorData" in response && "signature" in response;
98
+
99
+ const toBufferSource = (base64url: string): ArrayBuffer => {
100
+ const bytes = base64UrlDecodeToBytes(base64url);
101
+ // Copy into a standalone ArrayBuffer: BufferSource typing requires a
102
+ // non-shared, non-resizable backing buffer.
103
+ const buffer = new ArrayBuffer(bytes.byteLength);
104
+ new Uint8Array(buffer).set(bytes);
105
+ return buffer;
106
+ };
107
+
108
+ const extensionResults = (credential: PublicKeyCredential): Record<string, unknown> | undefined => {
109
+ const results = credential.getClientExtensionResults();
110
+ const entries = Object.entries(results);
111
+ if (entries.length === 0) return undefined;
112
+ const out: Record<string, unknown> = {};
113
+ for (const [key, value] of entries) {
114
+ out[key] = value;
115
+ }
116
+ return out;
117
+ };
118
+
119
+ const toAttachment = (value: string): "platform" | "cross-platform" =>
120
+ value === "platform" ? "platform" : "cross-platform";
121
+
122
+ const serializeRegistrationCredential = (
123
+ credential: PublicKeyCredential,
124
+ ): PasskeyRegistrationCredential | null => {
125
+ const { response } = credential;
126
+ if (!isAttestationResponse(response)) return null;
127
+ const transports = readTransports(response);
128
+ const publicKey = typeof response.getPublicKey === "function" ? response.getPublicKey() : null;
129
+ const authenticatorData =
130
+ typeof response.getAuthenticatorData === "function" ? response.getAuthenticatorData() : null;
131
+ const publicKeyAlgorithm =
132
+ typeof response.getPublicKeyAlgorithm === "function"
133
+ ? response.getPublicKeyAlgorithm()
134
+ : undefined;
135
+ const extensions = extensionResults(credential);
136
+ return {
137
+ id: credential.id,
138
+ rawId: bufferToBase64Url(credential.rawId),
139
+ type: "public-key",
140
+ response: {
141
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
142
+ attestationObject: bufferToBase64Url(response.attestationObject),
143
+ ...(transports !== undefined ? { transports } : {}),
144
+ ...(publicKeyAlgorithm !== undefined ? { publicKeyAlgorithm } : {}),
145
+ ...(publicKey !== null ? { publicKey: bufferToBase64Url(publicKey) } : {}),
146
+ ...(authenticatorData !== null
147
+ ? { authenticatorData: bufferToBase64Url(authenticatorData) }
148
+ : {}),
149
+ },
150
+ ...(credential.authenticatorAttachment !== null
151
+ ? { authenticatorAttachment: toAttachment(credential.authenticatorAttachment) }
152
+ : {}),
153
+ ...(extensions !== undefined ? { clientExtensionResults: extensions } : {}),
154
+ };
155
+ };
156
+
157
+ const serializeAuthenticationCredential = (
158
+ credential: PublicKeyCredential,
159
+ ): PasskeyAuthenticationCredential | null => {
160
+ const { response } = credential;
161
+ if (!isAssertionResponse(response)) return null;
162
+ const extensions = extensionResults(credential);
163
+ return {
164
+ id: credential.id,
165
+ rawId: bufferToBase64Url(credential.rawId),
166
+ type: "public-key",
167
+ response: {
168
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
169
+ authenticatorData: bufferToBase64Url(response.authenticatorData),
170
+ signature: bufferToBase64Url(response.signature),
171
+ ...(response.userHandle !== null
172
+ ? { userHandle: bufferToBase64Url(response.userHandle) }
173
+ : {}),
174
+ },
175
+ ...(credential.authenticatorAttachment !== null
176
+ ? { authenticatorAttachment: toAttachment(credential.authenticatorAttachment) }
177
+ : {}),
178
+ ...(extensions !== undefined ? { clientExtensionResults: extensions } : {}),
179
+ };
180
+ };
181
+
182
+ const isWebAuthnSupported = (): boolean =>
183
+ typeof navigator !== "undefined" &&
184
+ typeof navigator.credentials !== "undefined" &&
185
+ typeof PublicKeyCredential !== "undefined";
186
+
187
+ const toCreationOptions = (
188
+ options: PasskeyRegistrationOptionsResponse,
189
+ ): PublicKeyCredentialCreationOptions => ({
190
+ rp: { id: options.rp.id, name: options.rp.name },
191
+ user: {
192
+ id: toBufferSource(options.user.id),
193
+ name: options.user.name,
194
+ displayName: options.user.displayName,
195
+ },
196
+ challenge: toBufferSource(options.challenge),
197
+ pubKeyCredParams: options.pubKeyCredParams.map((param) => ({ type: param.type, alg: param.alg })),
198
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
199
+ ...(options.excludeCredentials !== undefined
200
+ ? {
201
+ excludeCredentials: options.excludeCredentials.map((descriptor) => ({
202
+ id: toBufferSource(descriptor.id),
203
+ type: descriptor.type,
204
+ ...(descriptor.transports !== undefined
205
+ ? { transports: descriptor.transports.filter(isTransport) }
206
+ : {}),
207
+ })),
208
+ }
209
+ : {}),
210
+ // design.md §17.1: residentKey/userVerification/attestation are fixed by contract.
211
+ authenticatorSelection: {
212
+ ...(options.authenticatorSelection.authenticatorAttachment !== undefined
213
+ ? { authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment }
214
+ : {}),
215
+ residentKey: options.authenticatorSelection.residentKey,
216
+ userVerification: options.authenticatorSelection.userVerification,
217
+ },
218
+ attestation: options.attestation,
219
+ });
220
+
221
+ const toRequestOptions = (
222
+ options: PasskeyAuthenticationOptionsResponse,
223
+ ): PublicKeyCredentialRequestOptions => ({
224
+ challenge: toBufferSource(options.challenge),
225
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
226
+ rpId: options.rpId,
227
+ ...(options.allowCredentials !== undefined
228
+ ? {
229
+ allowCredentials: options.allowCredentials.map((descriptor) => ({
230
+ id: toBufferSource(descriptor.id),
231
+ type: descriptor.type,
232
+ ...(descriptor.transports !== undefined
233
+ ? { transports: descriptor.transports.filter(isTransport) }
234
+ : {}),
235
+ })),
236
+ }
237
+ : {}),
238
+ userVerification: options.userVerification,
239
+ });
240
+
241
+ const create = async (
242
+ options: PasskeyRegistrationOptionsResponse,
243
+ ): Promise<Outcome<PasskeyRegistrationCredential, WebAuthnFailureError>> => {
244
+ if (!isWebAuthnSupported()) return NOT_SUPPORTED;
245
+ // Options building (base64url decode) is synchronous and can throw; running
246
+ // it inside the promise chain folds that throw into the same rejection
247
+ // channel as the ceremony itself — no try statement needed.
248
+ const credentialOutcome = await Promise.resolve()
249
+ .then(() => toCreationOptions(options))
250
+ .then((publicKey) => navigator.credentials.create({ publicKey }))
251
+ .then(
252
+ (credential) => ok<Credential | null, WebAuthnFailureError>(credential),
253
+ (cause: unknown) => err<Credential | null, WebAuthnFailureError>(mapCeremonyException(cause)),
254
+ );
255
+ if (!credentialOutcome.ok) return credentialOutcome;
256
+ const credential = credentialOutcome.value;
257
+ if (credential === null || !isPublicKeyCredential(credential)) {
258
+ return failure("failed", "navigator.credentials.create returned no PublicKeyCredential");
259
+ }
260
+ const serialized = serializeRegistrationCredential(credential);
261
+ if (serialized === null) {
262
+ return failure("failed", "Authenticator response was not an attestation response");
263
+ }
264
+ return ok(serialized);
265
+ };
266
+
267
+ const get = async (
268
+ options: PasskeyAuthenticationOptionsResponse,
269
+ ): Promise<Outcome<PasskeyAuthenticationCredential, WebAuthnFailureError>> => {
270
+ if (!isWebAuthnSupported()) return NOT_SUPPORTED;
271
+ const credentialOutcome = await Promise.resolve()
272
+ .then(() => toRequestOptions(options))
273
+ .then((publicKey) => navigator.credentials.get({ publicKey }))
274
+ .then(
275
+ (credential) => ok<Credential | null, WebAuthnFailureError>(credential),
276
+ (cause: unknown) => err<Credential | null, WebAuthnFailureError>(mapCeremonyException(cause)),
277
+ );
278
+ if (!credentialOutcome.ok) return credentialOutcome;
279
+ const credential = credentialOutcome.value;
280
+ if (credential === null || !isPublicKeyCredential(credential)) {
281
+ return failure("failed", "navigator.credentials.get returned no PublicKeyCredential");
282
+ }
283
+ const serialized = serializeAuthenticationCredential(credential);
284
+ if (serialized === null) {
285
+ return failure("failed", "Authenticator response was not an assertion response");
286
+ }
287
+ return ok(serialized);
288
+ };
289
+
290
+ /** Default port: the browser's own platform authenticator via `navigator.credentials`. */
291
+ export const navigatorWebAuthnPort: WebAuthnBrowserPort = {
292
+ isAvailable: isWebAuthnSupported,
293
+ create,
294
+ get,
295
+ };