@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/LICENSE +202 -0
- package/dist/base64url.d.ts +12 -0
- package/dist/base64url.js +44 -0
- package/dist/client.d.ts +192 -0
- package/dist/client.js +551 -0
- package/dist/errors.d.ts +95 -0
- package/dist/errors.js +44 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +113 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +26 -0
- package/dist/pkce.d.ts +25 -0
- package/dist/pkce.js +21 -0
- package/dist/webauthn.d.ts +26 -0
- package/dist/webauthn.js +214 -0
- package/package.json +41 -0
- package/src/base64url.ts +47 -0
- package/src/client-me.test.ts +321 -0
- package/src/client-tokens.test.ts +378 -0
- package/src/client.test.ts +714 -0
- package/src/client.ts +1117 -0
- package/src/errors.ts +154 -0
- package/src/http.test.ts +225 -0
- package/src/http.ts +173 -0
- package/src/index.ts +68 -0
- package/src/pkce.test.ts +51 -0
- package/src/pkce.ts +43 -0
- package/src/webauthn.ts +295 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
/**
|
|
3
|
+
* Narrows an already-JSON-parsed body to the Problem Details shape. Field
|
|
4
|
+
* types are checked one by one; extension members are ignored (carried by the
|
|
5
|
+
* server for its own diagnostics, not part of the client contract).
|
|
6
|
+
*/
|
|
7
|
+
export const parseProblemDetailsWire = (value) => {
|
|
8
|
+
if (!isPlainObject(value))
|
|
9
|
+
return null;
|
|
10
|
+
if (typeof value["type"] !== "string" || typeof value["title"] !== "string")
|
|
11
|
+
return null;
|
|
12
|
+
if (typeof value["status"] !== "number" || !Number.isInteger(value["status"]))
|
|
13
|
+
return null;
|
|
14
|
+
const detail = value["detail"];
|
|
15
|
+
const code = value["code"];
|
|
16
|
+
const requestId = value["request_id"];
|
|
17
|
+
if (detail !== undefined && typeof detail !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
if (code !== undefined && typeof code !== "string")
|
|
20
|
+
return null;
|
|
21
|
+
if (requestId !== undefined && typeof requestId !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
type: value["type"],
|
|
25
|
+
title: value["title"],
|
|
26
|
+
status: value["status"],
|
|
27
|
+
...(detail !== undefined ? { detail } : {}),
|
|
28
|
+
...(code !== undefined ? { code } : {}),
|
|
29
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export const parseOAuthErrorWire = (value) => {
|
|
33
|
+
if (!isPlainObject(value))
|
|
34
|
+
return null;
|
|
35
|
+
if (typeof value["error"] !== "string" || value["error"].length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
const description = value["error_description"];
|
|
38
|
+
if (description !== undefined && typeof description !== "string")
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
error: value["error"],
|
|
42
|
+
...(description !== undefined ? { description } : {}),
|
|
43
|
+
};
|
|
44
|
+
};
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Outcome } from "@smartcrab/contracts-public";
|
|
2
|
+
import type { OAuthErrorCode } from "@smartcrab/contracts-public";
|
|
3
|
+
import type { BrowserAuthError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* HTTP transport for the public API (design.md §16.2) and the token endpoint
|
|
6
|
+
* (design.md §15.1).
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - exact request serialization (JSON bodies, Bearer header, query strings)
|
|
10
|
+
* - response normalization into `BrowserAuthError` (network failure, 429 with
|
|
11
|
+
* `Retry-After`, RFC 9457 Problem Details, RFC 6749 OAuth errors, and
|
|
12
|
+
* `invalid_response` for anything that does not match the wire contract)
|
|
13
|
+
* - success-path validation through the contracts-public zod schemas — the
|
|
14
|
+
* wire is never trusted (IMPL_NOTES §1)
|
|
15
|
+
*
|
|
16
|
+
* Exception boundaries are promise rejection handlers (`.then(onOk, onErr)`),
|
|
17
|
+
* never `try`/`catch` statements: `scripts/check-backend-error-style.ts`
|
|
18
|
+
* (design.md §5.2) scans this package and rejects direct try-catch.
|
|
19
|
+
*/
|
|
20
|
+
/** Minimal structural view of a zod schema's `safeParse` (this package has no zod dependency). */
|
|
21
|
+
export interface WireSchema<T> {
|
|
22
|
+
safeParse(input: unknown): {
|
|
23
|
+
success: true;
|
|
24
|
+
data: T;
|
|
25
|
+
} | {
|
|
26
|
+
success: false;
|
|
27
|
+
error: unknown;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export type FetchPort = (url: string, init: RequestInit) => Promise<Response>;
|
|
31
|
+
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
32
|
+
export interface JsonRequest {
|
|
33
|
+
readonly method: HttpMethod;
|
|
34
|
+
/** Fully-built URL, including query string. */
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly body?: unknown;
|
|
37
|
+
readonly accessToken?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare const isOAuthErrorCode: (value: string) => value is OAuthErrorCode;
|
|
40
|
+
/**
|
|
41
|
+
* Executes one JSON request and validates the response against `schema`.
|
|
42
|
+
*
|
|
43
|
+
* Error precedence on non-2xx: 429 → `rate_limited`; a valid Problem Details
|
|
44
|
+
* body → `problem_details`; a valid OAuth error body → `oauth_error`;
|
|
45
|
+
* anything else → `invalid_response`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const requestJson: <T>(fetchPort: FetchPort, request: JsonRequest, schema: WireSchema<T>) => Promise<Outcome<T, BrowserAuthError>>;
|
|
48
|
+
/** Builds `base + path` plus an optional query string. */
|
|
49
|
+
export declare const buildUrl: (baseUrl: string, path: string, query?: Record<string, string>) => string;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { err, ok } from "@smartcrab/contracts-public";
|
|
2
|
+
import { OAUTH_ERROR_CODES } from "@smartcrab/contracts-public";
|
|
3
|
+
import { parseOAuthErrorWire, parseProblemDetailsWire } from "./errors.js";
|
|
4
|
+
const OAUTH_ERROR_CODE_SET = new Set(OAUTH_ERROR_CODES);
|
|
5
|
+
export const isOAuthErrorCode = (value) => OAUTH_ERROR_CODE_SET.has(value);
|
|
6
|
+
const parseRetryAfterSeconds = (response) => {
|
|
7
|
+
const raw = response.headers.get("retry-after");
|
|
8
|
+
if (raw === null)
|
|
9
|
+
return undefined;
|
|
10
|
+
// Only the delta-seconds form is honored; HTTP-date form is intentionally
|
|
11
|
+
// unsupported (the platform always emits delta-seconds).
|
|
12
|
+
if (!/^[0-9]+$/.test(raw.trim()))
|
|
13
|
+
return undefined;
|
|
14
|
+
return Number.parseInt(raw.trim(), 10);
|
|
15
|
+
};
|
|
16
|
+
const invalidResponse = (message, status) => err({
|
|
17
|
+
type: "invalid_response",
|
|
18
|
+
message,
|
|
19
|
+
...(status !== undefined ? { status } : {}),
|
|
20
|
+
});
|
|
21
|
+
const toNetworkError = (cause) => err({
|
|
22
|
+
type: "network_error",
|
|
23
|
+
message: cause instanceof Error ? cause.message : "Network request failed",
|
|
24
|
+
retryable: true,
|
|
25
|
+
});
|
|
26
|
+
/** `JSON.parse` without a try statement: the throw becomes a rejection inside the promise chain. */
|
|
27
|
+
const parseJsonBody = (text, status) => Promise.resolve()
|
|
28
|
+
.then(() => JSON.parse(text))
|
|
29
|
+
.then((value) => ok(value), () => invalidResponse("Response body was not valid JSON", status));
|
|
30
|
+
/**
|
|
31
|
+
* Executes one JSON request and validates the response against `schema`.
|
|
32
|
+
*
|
|
33
|
+
* Error precedence on non-2xx: 429 → `rate_limited`; a valid Problem Details
|
|
34
|
+
* body → `problem_details`; a valid OAuth error body → `oauth_error`;
|
|
35
|
+
* anything else → `invalid_response`.
|
|
36
|
+
*/
|
|
37
|
+
export const requestJson = async (fetchPort, request, schema) => {
|
|
38
|
+
const headers = {
|
|
39
|
+
accept: "application/json",
|
|
40
|
+
...(request.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
41
|
+
...(request.accessToken !== undefined
|
|
42
|
+
? { authorization: `Bearer ${request.accessToken}` }
|
|
43
|
+
: {}),
|
|
44
|
+
};
|
|
45
|
+
const responseOutcome = await fetchPort(request.url, {
|
|
46
|
+
method: request.method,
|
|
47
|
+
headers,
|
|
48
|
+
...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}),
|
|
49
|
+
}).then((response) => ok(response), toNetworkError);
|
|
50
|
+
if (!responseOutcome.ok)
|
|
51
|
+
return responseOutcome;
|
|
52
|
+
const response = responseOutcome.value;
|
|
53
|
+
// A rejection while streaming the body is still a transport failure.
|
|
54
|
+
const textOutcome = await response
|
|
55
|
+
.text()
|
|
56
|
+
.then((text) => ok(text), toNetworkError);
|
|
57
|
+
if (!textOutcome.ok)
|
|
58
|
+
return textOutcome;
|
|
59
|
+
const text = textOutcome.value;
|
|
60
|
+
const jsonOutcome = text.length === 0 ? ok(undefined) : await parseJsonBody(text, response.status);
|
|
61
|
+
if (!jsonOutcome.ok)
|
|
62
|
+
return jsonOutcome;
|
|
63
|
+
const json = jsonOutcome.value;
|
|
64
|
+
if (response.status === 429) {
|
|
65
|
+
const retryAfterSeconds = parseRetryAfterSeconds(response);
|
|
66
|
+
const problem = parseProblemDetailsWire(json);
|
|
67
|
+
return err({
|
|
68
|
+
type: "rate_limited",
|
|
69
|
+
retryable: true,
|
|
70
|
+
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
|
|
71
|
+
...(problem !== null && problem.requestId !== undefined
|
|
72
|
+
? { requestId: problem.requestId }
|
|
73
|
+
: {}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const problem = parseProblemDetailsWire(json);
|
|
78
|
+
if (problem !== null) {
|
|
79
|
+
return err({
|
|
80
|
+
type: "problem_details",
|
|
81
|
+
problemType: problem.type,
|
|
82
|
+
code: problem.code ?? "unknown_error",
|
|
83
|
+
title: problem.title,
|
|
84
|
+
status: problem.status,
|
|
85
|
+
...(problem.detail !== undefined ? { detail: problem.detail } : {}),
|
|
86
|
+
...(problem.requestId !== undefined ? { requestId: problem.requestId } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const oauthError = parseOAuthErrorWire(json);
|
|
90
|
+
if (oauthError !== null && isOAuthErrorCode(oauthError.error)) {
|
|
91
|
+
return err({
|
|
92
|
+
type: "oauth_error",
|
|
93
|
+
code: oauthError.error,
|
|
94
|
+
status: response.status,
|
|
95
|
+
...(oauthError.description !== undefined ? { description: oauthError.description } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return invalidResponse(`HTTP ${response.status} error body was neither Problem Details nor an OAuth error`, response.status);
|
|
99
|
+
}
|
|
100
|
+
const parsed = schema.safeParse(json);
|
|
101
|
+
if (!parsed.success) {
|
|
102
|
+
return invalidResponse("Response body did not match the public API contract", response.status);
|
|
103
|
+
}
|
|
104
|
+
return ok(parsed.data);
|
|
105
|
+
};
|
|
106
|
+
/** Builds `base + path` plus an optional query string. */
|
|
107
|
+
export const buildUrl = (baseUrl, path, query) => {
|
|
108
|
+
const url = `${baseUrl}${path}`;
|
|
109
|
+
if (query === undefined || Object.keys(query).length === 0)
|
|
110
|
+
return url;
|
|
111
|
+
const params = new URLSearchParams(query);
|
|
112
|
+
return `${url}?${params.toString()}`;
|
|
113
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @smartcrab/browser — dependency-free SPA client for the passwordless
|
|
3
|
+
* auth platform's public API (design.md §16 transaction API, §15 token
|
|
4
|
+
* endpoint, §22.3 SPA rules).
|
|
5
|
+
*
|
|
6
|
+
* - Every request/response goes through the `@smartcrab/contracts-public`
|
|
7
|
+
* zod schemas; malformed wire data surfaces as typed `invalid_response`
|
|
8
|
+
* failures, never as trusted objects.
|
|
9
|
+
* - Access tokens live in memory only (design.md §22.3); refresh-token
|
|
10
|
+
* persistence is an explicit, documented opt-in.
|
|
11
|
+
* - Fallible methods return `Outcome<T, BrowserAuthError>` — the local
|
|
12
|
+
* discriminated-union channel from contracts-public, because
|
|
13
|
+
* `@praha/byethrow` is not resolvable from this package (not a declared
|
|
14
|
+
* dependency).
|
|
15
|
+
*/
|
|
16
|
+
export { createBrowserAuthClient } from "./client.js";
|
|
17
|
+
export type { AuthenticatedSession, BrowserAuthClient, BrowserAuthClientConfig, BrowserAuthListener, BrowserAuthSnapshot, BrowserTransaction, CreateTransactionInput, HostedAuthorization, RedirectPort, RefreshTokenStoragePort, } from "./client.js";
|
|
18
|
+
export { parseOAuthErrorWire, parseProblemDetailsWire } from "./errors.js";
|
|
19
|
+
export type { BrowserAuthError, InvalidResponseError, NetworkFailureError, NotAuthenticatedError, OAuthErrorWire, OAuthFailureError, ProblemDetailsError, ProblemDetailsWire, RateLimitedError, StateMismatchError, WebAuthnFailureError, WebAuthnFailureReason, } from "./errors.js";
|
|
20
|
+
export { buildUrl, requestJson } from "./http.js";
|
|
21
|
+
export type { FetchPort, HttpMethod, JsonRequest, WireSchema } from "./http.js";
|
|
22
|
+
export { generateNonce, generatePkcePair, generateState } from "./pkce.js";
|
|
23
|
+
export type { PkcePair } from "./pkce.js";
|
|
24
|
+
export { base64UrlDecodeToBytes, base64UrlEncodeBytes, bufferToBase64Url } from "./base64url.js";
|
|
25
|
+
export { navigatorWebAuthnPort } from "./webauthn.js";
|
|
26
|
+
export type { WebAuthnBrowserPort } from "./webauthn.js";
|
|
27
|
+
export { err, ok } from "@smartcrab/contracts-public";
|
|
28
|
+
export type { EmailChallengeResponse, MeIdentitySummary, MeSessionSummary, MeUpdateRequest, MeUser, Outcome, PasskeySummary, PublicConfigResponse, SocialProvider, TransactionStatus, } from "@smartcrab/contracts-public";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @smartcrab/browser — dependency-free SPA client for the passwordless
|
|
3
|
+
* auth platform's public API (design.md §16 transaction API, §15 token
|
|
4
|
+
* endpoint, §22.3 SPA rules).
|
|
5
|
+
*
|
|
6
|
+
* - Every request/response goes through the `@smartcrab/contracts-public`
|
|
7
|
+
* zod schemas; malformed wire data surfaces as typed `invalid_response`
|
|
8
|
+
* failures, never as trusted objects.
|
|
9
|
+
* - Access tokens live in memory only (design.md §22.3); refresh-token
|
|
10
|
+
* persistence is an explicit, documented opt-in.
|
|
11
|
+
* - Fallible methods return `Outcome<T, BrowserAuthError>` — the local
|
|
12
|
+
* discriminated-union channel from contracts-public, because
|
|
13
|
+
* `@praha/byethrow` is not resolvable from this package (not a declared
|
|
14
|
+
* dependency).
|
|
15
|
+
*/
|
|
16
|
+
export { createBrowserAuthClient } from "./client.js";
|
|
17
|
+
export { parseOAuthErrorWire, parseProblemDetailsWire } from "./errors.js";
|
|
18
|
+
export { buildUrl, requestJson } from "./http.js";
|
|
19
|
+
export { generateNonce, generatePkcePair, generateState } from "./pkce.js";
|
|
20
|
+
export { base64UrlDecodeToBytes, base64UrlEncodeBytes, bufferToBase64Url } from "./base64url.js";
|
|
21
|
+
export { navigatorWebAuthnPort } from "./webauthn.js";
|
|
22
|
+
// Selected contract types re-exported so downstream SDKs (e.g.
|
|
23
|
+
// `@smartcrab/react`, which cannot depend on contracts-public directly)
|
|
24
|
+
// can type their own surfaces against the exact wire shapes. `ok`/`err` are
|
|
25
|
+
// re-exported as values so consumers can construct `Outcome`s themselves.
|
|
26
|
+
export { err, ok } from "@smartcrab/contracts-public";
|
package/dist/pkce.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) and anti-replay value generation for the SPA flow
|
|
3
|
+
* (design.md §22.3, §32.1: Authorization Code + PKCE S256, state/nonce 必須).
|
|
4
|
+
*
|
|
5
|
+
* Uses the bare `crypto` WebCrypto global only (`crypto.getRandomValues`,
|
|
6
|
+
* `crypto.subtle.digest`) — never `globalThis.crypto`, never Node builtins —
|
|
7
|
+
* so the module works identically in browsers and Workers-like runtimes
|
|
8
|
+
* (IMPL_NOTES §5).
|
|
9
|
+
*/
|
|
10
|
+
export interface PkcePair {
|
|
11
|
+
/** RFC 7636 §4.1 verifier: 43 chars from 32 random bytes, unreserved charset. */
|
|
12
|
+
readonly verifier: string;
|
|
13
|
+
/** RFC 7636 §4.2 S256 challenge: base64url(SHA-256(verifier)), 43 chars. */
|
|
14
|
+
readonly challenge: string;
|
|
15
|
+
readonly method: "S256";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Transaction `state` (design.md §16.1: the server stores only a MAC of it).
|
|
19
|
+
* 256 bits of entropy, base64url — opaque to the server.
|
|
20
|
+
*/
|
|
21
|
+
export declare const generateState: () => string;
|
|
22
|
+
/** OIDC `nonce` for ID token replay protection (design.md §32.1). */
|
|
23
|
+
export declare const generateNonce: () => string;
|
|
24
|
+
/** Generates a verifier/challenge pair; `plain` is never offered (design.md §15.3). */
|
|
25
|
+
export declare const generatePkcePair: () => Promise<PkcePair>;
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { base64UrlEncodeBytes } from "./base64url.js";
|
|
2
|
+
const randomBase64Url = (byteLength) => {
|
|
3
|
+
const bytes = new Uint8Array(byteLength);
|
|
4
|
+
crypto.getRandomValues(bytes);
|
|
5
|
+
return base64UrlEncodeBytes(bytes);
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Transaction `state` (design.md §16.1: the server stores only a MAC of it).
|
|
9
|
+
* 256 bits of entropy, base64url — opaque to the server.
|
|
10
|
+
*/
|
|
11
|
+
export const generateState = () => randomBase64Url(32);
|
|
12
|
+
/** OIDC `nonce` for ID token replay protection (design.md §32.1). */
|
|
13
|
+
export const generateNonce = () => randomBase64Url(32);
|
|
14
|
+
/** Generates a verifier/challenge pair; `plain` is never offered (design.md §15.3). */
|
|
15
|
+
export const generatePkcePair = async () => {
|
|
16
|
+
// 32 bytes → 43 base64url chars: inside the RFC 7636 §4.1 43–128 window and
|
|
17
|
+
// matches the contracts-public `codeVerifierSchema` charset exactly.
|
|
18
|
+
const verifier = randomBase64Url(32);
|
|
19
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
20
|
+
return { verifier, challenge: base64UrlEncodeBytes(new Uint8Array(digest)), method: "S256" };
|
|
21
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PasskeyAuthenticationCredential, PasskeyAuthenticationOptionsResponse, PasskeyRegistrationCredential, PasskeyRegistrationOptionsResponse } from "@smartcrab/contracts-public";
|
|
2
|
+
import type { Outcome } from "@smartcrab/contracts-public";
|
|
3
|
+
import type { WebAuthnFailureError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* WebAuthn ceremony boundary (design.md §17, §22.3).
|
|
6
|
+
*
|
|
7
|
+
* `@simplewebauthn/browser` is deliberately NOT a dependency of this SDK: the
|
|
8
|
+
* surface we need is two `navigator.credentials` calls plus base64url
|
|
9
|
+
* (de)serialization, so the SDK ships dependency-free behind this minimal
|
|
10
|
+
* port. Tests and non-standard runtimes inject their own implementation;
|
|
11
|
+
* production uses `navigatorWebAuthnPort`.
|
|
12
|
+
*
|
|
13
|
+
* Exception boundaries are promise rejection handlers, never `try`/`catch`
|
|
14
|
+
* statements: `scripts/check-backend-error-style.ts` (design.md §5.2) scans
|
|
15
|
+
* this package and rejects direct try-catch.
|
|
16
|
+
*/
|
|
17
|
+
export interface WebAuthnBrowserPort {
|
|
18
|
+
/** Runtime capability check (design.md §21.2: UI hides passkeys when false). */
|
|
19
|
+
isAvailable(): boolean;
|
|
20
|
+
/** `startRegistration`-style ceremony: JSON options in, JSON credential out. */
|
|
21
|
+
create(options: PasskeyRegistrationOptionsResponse): Promise<Outcome<PasskeyRegistrationCredential, WebAuthnFailureError>>;
|
|
22
|
+
/** `startAuthentication`-style ceremony: JSON options in, JSON assertion out. */
|
|
23
|
+
get(options: PasskeyAuthenticationOptionsResponse): Promise<Outcome<PasskeyAuthenticationCredential, WebAuthnFailureError>>;
|
|
24
|
+
}
|
|
25
|
+
/** Default port: the browser's own platform authenticator via `navigator.credentials`. */
|
|
26
|
+
export declare const navigatorWebAuthnPort: WebAuthnBrowserPort;
|
package/dist/webauthn.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { err, ok } from "@smartcrab/contracts-public";
|
|
2
|
+
import { base64UrlDecodeToBytes, bufferToBase64Url } from "./base64url.js";
|
|
3
|
+
const failure = (reason, message) => err({ type: "webauthn_error", reason, message });
|
|
4
|
+
const NOT_SUPPORTED = err({
|
|
5
|
+
type: "webauthn_error",
|
|
6
|
+
reason: "not_supported",
|
|
7
|
+
message: "WebAuthn (PublicKeyCredential) is not available in this environment",
|
|
8
|
+
});
|
|
9
|
+
/** Normalizes DOMException names from the authenticator into the public error reasons. */
|
|
10
|
+
const mapCeremonyException = (cause) => {
|
|
11
|
+
if (cause instanceof DOMException) {
|
|
12
|
+
switch (cause.name) {
|
|
13
|
+
case "NotAllowedError":
|
|
14
|
+
case "AbortError":
|
|
15
|
+
// User dismissal or ceremony timeout (WebAuthn Level 3 §6.1).
|
|
16
|
+
return { type: "webauthn_error", reason: "cancelled", message: cause.message };
|
|
17
|
+
case "NotSupportedError":
|
|
18
|
+
return { type: "webauthn_error", reason: "not_supported", message: cause.message };
|
|
19
|
+
default:
|
|
20
|
+
return { type: "webauthn_error", reason: "failed", message: cause.message };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const message = cause instanceof Error ? cause.message : "Unknown WebAuthn failure";
|
|
24
|
+
return { type: "webauthn_error", reason: "failed", message };
|
|
25
|
+
};
|
|
26
|
+
const AUTHENTICATOR_TRANSPORTS = ["ble", "hybrid", "internal", "nfc", "usb"];
|
|
27
|
+
const isTransport = (value) => AUTHENTICATOR_TRANSPORTS.includes(value);
|
|
28
|
+
/** `getTransports()` is Level 3; older engines lack it, hence the feature detection. */
|
|
29
|
+
const readTransports = (candidate) => {
|
|
30
|
+
if (typeof candidate.getTransports !== "function")
|
|
31
|
+
return undefined;
|
|
32
|
+
const raw = candidate.getTransports();
|
|
33
|
+
if (!Array.isArray(raw))
|
|
34
|
+
return undefined;
|
|
35
|
+
const transports = raw
|
|
36
|
+
.filter((entry) => typeof entry === "string")
|
|
37
|
+
.filter(isTransport);
|
|
38
|
+
return transports.length > 0 ? transports : undefined;
|
|
39
|
+
};
|
|
40
|
+
const isPublicKeyCredential = (credential) => credential.type === "public-key" && "rawId" in credential && "response" in credential;
|
|
41
|
+
const isAttestationResponse = (response) => "attestationObject" in response;
|
|
42
|
+
const isAssertionResponse = (response) => "authenticatorData" in response && "signature" in response;
|
|
43
|
+
const toBufferSource = (base64url) => {
|
|
44
|
+
const bytes = base64UrlDecodeToBytes(base64url);
|
|
45
|
+
// Copy into a standalone ArrayBuffer: BufferSource typing requires a
|
|
46
|
+
// non-shared, non-resizable backing buffer.
|
|
47
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
48
|
+
new Uint8Array(buffer).set(bytes);
|
|
49
|
+
return buffer;
|
|
50
|
+
};
|
|
51
|
+
const extensionResults = (credential) => {
|
|
52
|
+
const results = credential.getClientExtensionResults();
|
|
53
|
+
const entries = Object.entries(results);
|
|
54
|
+
if (entries.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [key, value] of entries) {
|
|
58
|
+
out[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const toAttachment = (value) => value === "platform" ? "platform" : "cross-platform";
|
|
63
|
+
const serializeRegistrationCredential = (credential) => {
|
|
64
|
+
const { response } = credential;
|
|
65
|
+
if (!isAttestationResponse(response))
|
|
66
|
+
return null;
|
|
67
|
+
const transports = readTransports(response);
|
|
68
|
+
const publicKey = typeof response.getPublicKey === "function" ? response.getPublicKey() : null;
|
|
69
|
+
const authenticatorData = typeof response.getAuthenticatorData === "function" ? response.getAuthenticatorData() : null;
|
|
70
|
+
const publicKeyAlgorithm = typeof response.getPublicKeyAlgorithm === "function"
|
|
71
|
+
? response.getPublicKeyAlgorithm()
|
|
72
|
+
: undefined;
|
|
73
|
+
const extensions = extensionResults(credential);
|
|
74
|
+
return {
|
|
75
|
+
id: credential.id,
|
|
76
|
+
rawId: bufferToBase64Url(credential.rawId),
|
|
77
|
+
type: "public-key",
|
|
78
|
+
response: {
|
|
79
|
+
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
|
80
|
+
attestationObject: bufferToBase64Url(response.attestationObject),
|
|
81
|
+
...(transports !== undefined ? { transports } : {}),
|
|
82
|
+
...(publicKeyAlgorithm !== undefined ? { publicKeyAlgorithm } : {}),
|
|
83
|
+
...(publicKey !== null ? { publicKey: bufferToBase64Url(publicKey) } : {}),
|
|
84
|
+
...(authenticatorData !== null
|
|
85
|
+
? { authenticatorData: bufferToBase64Url(authenticatorData) }
|
|
86
|
+
: {}),
|
|
87
|
+
},
|
|
88
|
+
...(credential.authenticatorAttachment !== null
|
|
89
|
+
? { authenticatorAttachment: toAttachment(credential.authenticatorAttachment) }
|
|
90
|
+
: {}),
|
|
91
|
+
...(extensions !== undefined ? { clientExtensionResults: extensions } : {}),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const serializeAuthenticationCredential = (credential) => {
|
|
95
|
+
const { response } = credential;
|
|
96
|
+
if (!isAssertionResponse(response))
|
|
97
|
+
return null;
|
|
98
|
+
const extensions = extensionResults(credential);
|
|
99
|
+
return {
|
|
100
|
+
id: credential.id,
|
|
101
|
+
rawId: bufferToBase64Url(credential.rawId),
|
|
102
|
+
type: "public-key",
|
|
103
|
+
response: {
|
|
104
|
+
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
|
105
|
+
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
|
106
|
+
signature: bufferToBase64Url(response.signature),
|
|
107
|
+
...(response.userHandle !== null
|
|
108
|
+
? { userHandle: bufferToBase64Url(response.userHandle) }
|
|
109
|
+
: {}),
|
|
110
|
+
},
|
|
111
|
+
...(credential.authenticatorAttachment !== null
|
|
112
|
+
? { authenticatorAttachment: toAttachment(credential.authenticatorAttachment) }
|
|
113
|
+
: {}),
|
|
114
|
+
...(extensions !== undefined ? { clientExtensionResults: extensions } : {}),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const isWebAuthnSupported = () => typeof navigator !== "undefined" &&
|
|
118
|
+
typeof navigator.credentials !== "undefined" &&
|
|
119
|
+
typeof PublicKeyCredential !== "undefined";
|
|
120
|
+
const toCreationOptions = (options) => ({
|
|
121
|
+
rp: { id: options.rp.id, name: options.rp.name },
|
|
122
|
+
user: {
|
|
123
|
+
id: toBufferSource(options.user.id),
|
|
124
|
+
name: options.user.name,
|
|
125
|
+
displayName: options.user.displayName,
|
|
126
|
+
},
|
|
127
|
+
challenge: toBufferSource(options.challenge),
|
|
128
|
+
pubKeyCredParams: options.pubKeyCredParams.map((param) => ({ type: param.type, alg: param.alg })),
|
|
129
|
+
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
|
|
130
|
+
...(options.excludeCredentials !== undefined
|
|
131
|
+
? {
|
|
132
|
+
excludeCredentials: options.excludeCredentials.map((descriptor) => ({
|
|
133
|
+
id: toBufferSource(descriptor.id),
|
|
134
|
+
type: descriptor.type,
|
|
135
|
+
...(descriptor.transports !== undefined
|
|
136
|
+
? { transports: descriptor.transports.filter(isTransport) }
|
|
137
|
+
: {}),
|
|
138
|
+
})),
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
// design.md §17.1: residentKey/userVerification/attestation are fixed by contract.
|
|
142
|
+
authenticatorSelection: {
|
|
143
|
+
...(options.authenticatorSelection.authenticatorAttachment !== undefined
|
|
144
|
+
? { authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment }
|
|
145
|
+
: {}),
|
|
146
|
+
residentKey: options.authenticatorSelection.residentKey,
|
|
147
|
+
userVerification: options.authenticatorSelection.userVerification,
|
|
148
|
+
},
|
|
149
|
+
attestation: options.attestation,
|
|
150
|
+
});
|
|
151
|
+
const toRequestOptions = (options) => ({
|
|
152
|
+
challenge: toBufferSource(options.challenge),
|
|
153
|
+
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
|
|
154
|
+
rpId: options.rpId,
|
|
155
|
+
...(options.allowCredentials !== undefined
|
|
156
|
+
? {
|
|
157
|
+
allowCredentials: options.allowCredentials.map((descriptor) => ({
|
|
158
|
+
id: toBufferSource(descriptor.id),
|
|
159
|
+
type: descriptor.type,
|
|
160
|
+
...(descriptor.transports !== undefined
|
|
161
|
+
? { transports: descriptor.transports.filter(isTransport) }
|
|
162
|
+
: {}),
|
|
163
|
+
})),
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
166
|
+
userVerification: options.userVerification,
|
|
167
|
+
});
|
|
168
|
+
const create = async (options) => {
|
|
169
|
+
if (!isWebAuthnSupported())
|
|
170
|
+
return NOT_SUPPORTED;
|
|
171
|
+
// Options building (base64url decode) is synchronous and can throw; running
|
|
172
|
+
// it inside the promise chain folds that throw into the same rejection
|
|
173
|
+
// channel as the ceremony itself — no try statement needed.
|
|
174
|
+
const credentialOutcome = await Promise.resolve()
|
|
175
|
+
.then(() => toCreationOptions(options))
|
|
176
|
+
.then((publicKey) => navigator.credentials.create({ publicKey }))
|
|
177
|
+
.then((credential) => ok(credential), (cause) => err(mapCeremonyException(cause)));
|
|
178
|
+
if (!credentialOutcome.ok)
|
|
179
|
+
return credentialOutcome;
|
|
180
|
+
const credential = credentialOutcome.value;
|
|
181
|
+
if (credential === null || !isPublicKeyCredential(credential)) {
|
|
182
|
+
return failure("failed", "navigator.credentials.create returned no PublicKeyCredential");
|
|
183
|
+
}
|
|
184
|
+
const serialized = serializeRegistrationCredential(credential);
|
|
185
|
+
if (serialized === null) {
|
|
186
|
+
return failure("failed", "Authenticator response was not an attestation response");
|
|
187
|
+
}
|
|
188
|
+
return ok(serialized);
|
|
189
|
+
};
|
|
190
|
+
const get = async (options) => {
|
|
191
|
+
if (!isWebAuthnSupported())
|
|
192
|
+
return NOT_SUPPORTED;
|
|
193
|
+
const credentialOutcome = await Promise.resolve()
|
|
194
|
+
.then(() => toRequestOptions(options))
|
|
195
|
+
.then((publicKey) => navigator.credentials.get({ publicKey }))
|
|
196
|
+
.then((credential) => ok(credential), (cause) => err(mapCeremonyException(cause)));
|
|
197
|
+
if (!credentialOutcome.ok)
|
|
198
|
+
return credentialOutcome;
|
|
199
|
+
const credential = credentialOutcome.value;
|
|
200
|
+
if (credential === null || !isPublicKeyCredential(credential)) {
|
|
201
|
+
return failure("failed", "navigator.credentials.get returned no PublicKeyCredential");
|
|
202
|
+
}
|
|
203
|
+
const serialized = serializeAuthenticationCredential(credential);
|
|
204
|
+
if (serialized === null) {
|
|
205
|
+
return failure("failed", "Authenticator response was not an assertion response");
|
|
206
|
+
}
|
|
207
|
+
return ok(serialized);
|
|
208
|
+
};
|
|
209
|
+
/** Default port: the browser's own platform authenticator via `navigator.credentials`. */
|
|
210
|
+
export const navigatorWebAuthnPort = {
|
|
211
|
+
isAvailable: isWebAuthnSupported,
|
|
212
|
+
create,
|
|
213
|
+
get,
|
|
214
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smartcrab/browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./*": {
|
|
16
|
+
"types": "./src/*.ts",
|
|
17
|
+
"development": "./src/*.ts",
|
|
18
|
+
"import": "./dist/*.js",
|
|
19
|
+
"default": "./dist/*.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@smartcrab/contracts-public": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "5.9.3",
|
|
34
|
+
"vitest": "4.1.11"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "vitest run --passWithNoTests",
|
|
39
|
+
"build": "tsc -p tsconfig.build.json"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/base64url.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local base64url (RFC 4648 §5, no padding) helpers.
|
|
3
|
+
*
|
|
4
|
+
* Duplicated intentionally from `@smartcrab/identifiers` rather than
|
|
5
|
+
* imported: that package is not a dependency of this SDK (only
|
|
6
|
+
* `@smartcrab/contracts-public` is), and adding it just for ~40 lines of
|
|
7
|
+
* alphabet math would widen the published dependency surface for no
|
|
8
|
+
* behavioral gain. Implementations are byte-for-byte compatible.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
12
|
+
|
|
13
|
+
export const base64UrlEncodeBytes = (bytes: Uint8Array): string => {
|
|
14
|
+
let out = "";
|
|
15
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
16
|
+
const b0 = bytes[i] ?? 0;
|
|
17
|
+
const b1 = bytes[i + 1];
|
|
18
|
+
const b2 = bytes[i + 2];
|
|
19
|
+
out += BASE64URL_ALPHABET[b0 >> 2] ?? "";
|
|
20
|
+
out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)] ?? "";
|
|
21
|
+
if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)] ?? "";
|
|
22
|
+
if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f] ?? "";
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const base64UrlDecodeToBytes = (encoded: string): Uint8Array => {
|
|
28
|
+
const out: number[] = [];
|
|
29
|
+
let buffer = 0;
|
|
30
|
+
let bits = 0;
|
|
31
|
+
for (const char of encoded) {
|
|
32
|
+
const index = BASE64URL_ALPHABET.indexOf(char);
|
|
33
|
+
if (index < 0) {
|
|
34
|
+
throw new Error(`Invalid base64url character: ${char}`);
|
|
35
|
+
}
|
|
36
|
+
buffer = (buffer << 6) | index;
|
|
37
|
+
bits += 6;
|
|
38
|
+
if (bits >= 8) {
|
|
39
|
+
bits -= 8;
|
|
40
|
+
out.push((buffer >>> bits) & 0xff);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return new Uint8Array(out);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const bufferToBase64Url = (buffer: ArrayBuffer): string =>
|
|
47
|
+
base64UrlEncodeBytes(new Uint8Array(buffer));
|