feat: centralize auth REST contracts

This commit is contained in:
2026-09-03 17:35:24 +09:00
parent c1dfb1add5
commit bc810beb3b
11 changed files with 1553 additions and 431 deletions
+106
View File
@@ -0,0 +1,106 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts
export type AuthPublicConfig = {
rp_id: string;
origin: string;
public_base_url: string;
cookie_name: string;
};
export type ActorAuthMethod = "browser_session" | "api_token";
export type AuthenticatedUser = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
};
export type RequestActor = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
auth_method: ActorAuthMethod;
};
export type WhoamiResponse = { actor: RequestActor | null };
export type AuthBootstrapUserRequest = {
handle: string;
display_name?: string | null;
};
export type AuthUserResponse = { user: AuthenticatedUser };
export type PasskeyRegistrationOptionsRequest = {
handle: string;
display_name?: string | null;
browser_origin?: string | null;
};
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: unknown;
};
export type PasskeyRegistrationCompleteRequest = {
challenge_id: string;
credential: unknown;
};
export type PasskeyLoginOptionsRequest = {
handle?: string | null;
browser_origin?: string | null;
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: unknown;
};
export type PasskeyLoginCompleteRequest = {
challenge_id: string;
credential: unknown;
};
export type DeviceLoginStartRequest = { client_name?: string | null };
export type DeviceLoginStartResponse = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
};
export type DeviceLoginApproveRequest = { user_code: string };
export type DeviceLoginApprovalStatus = "approved";
export type DeviceLoginApproveResponse = {
status: DeviceLoginApprovalStatus;
user: AuthenticatedUser;
};
export type DeviceLoginPollRequest = { device_code: string };
export type DeviceAccessTokenType = "Bearer";
export type DeviceLoginPollStatus =
| "pending"
| "approved"
| "expired"
| "consumed";
export type DeviceLoginPollResponse = {
status: DeviceLoginPollStatus;
access_token?: string | null;
token_type?: DeviceAccessTokenType | null;
};
export type LogoutStatus = "logged_out";
export type LogoutResponse = { status: LogoutStatus };
+104 -79
View File
@@ -1,118 +1,143 @@
import type {
DeviceLoginApproveRequest,
PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsRequest,
} from "$lib/generated/auth-api.ts";
import {
authenticationCredentialToJson,
type AuthUser,
type DeviceApprovalResponse,
isPublicKeyCredential,
type PasskeyLoginOptionsResponse,
type PasskeyRegistrationOptionsResponse,
type PasskeyUserResponse,
parseAuthUserResponse,
parseDeviceApprovalResponse,
parseLogoutResponse,
parseWhoamiResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type WhoamiResponse,
} from "./model";
} from "$lib/workspace/auth/model";
async function jsonOrThrow<T>(response: Response): Promise<T> {
const text = await response.text();
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(path, {
credentials: "same-origin",
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const body = await response.json() as unknown;
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`,
);
const errorBody = typeof body === "object" && body !== null
? body as Record<string, unknown>
: null;
const message = typeof errorBody?.message === "string"
? errorBody.message
: `Request failed (${response.status})`;
throw new Error(message);
}
return text ? JSON.parse(text) as T : (null as T);
return body;
}
function browserOrigin(): string | null {
return globalThis.location?.origin ?? null;
export async function loadWhoami(): Promise<WhoamiResponse> {
return parseWhoamiResponse(await requestJson("/api/auth/whoami"));
}
export async function loadWhoami(
fetcher: typeof fetch = fetch,
): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(
jsonOrThrow<WhoamiResponse>,
export async function logout(): Promise<void> {
parseLogoutResponse(
await requestJson("/api/auth/logout", {
method: "POST",
body: "{}",
}),
);
}
export async function registerPasskey(
handle: string,
displayName: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/registration/options", {
displayName?: string,
): Promise<AuthUser> {
const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle,
display_name: displayName ?? null,
browser_origin: window.location.origin,
};
const options = await requestJson("/api/auth/passkeys/registration/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
handle,
display_name: displayName,
browser_origin: browserOrigin(),
}),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
body: JSON.stringify(optionsRequest),
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: registration_options.challenge_id is required.",
);
}
const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error(
"Passkey registration did not return a public-key credential.",
);
}
if (!credential) throw new Error("Passkey registration was cancelled");
return await fetcher("/api/auth/passkeys/registration/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: registrationCredentialToJson(credential),
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/registration/complete", {
method: "POST",
body: JSON.stringify(completeRequest),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
);
return result.user;
}
export async function loginWithPasskey(
handle: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/login/options", {
export async function loginWithPasskey(handle?: string): Promise<AuthUser> {
const optionsRequest: PasskeyLoginOptionsRequest = {
handle: handle ?? null,
browser_origin: window.location.origin,
};
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle, browser_origin: browserOrigin() }),
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
body: JSON.stringify(optionsRequest),
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: login_options.challenge_id is required.",
);
}
const credential = await navigator.credentials.get({
publicKey: prepareLoginOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey login did not return a public-key credential.");
}
if (!credential) throw new Error("Passkey login was cancelled");
return await fetcher("/api/auth/passkeys/login/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: authenticationCredentialToJson(credential),
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", {
method: "POST",
body: JSON.stringify(completeRequest),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
}
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
await fetcher("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
}).then(jsonOrThrow<unknown>);
);
return result.user;
}
export async function approveDeviceLogin(
userCode: string,
fetcher: typeof fetch = fetch,
): Promise<DeviceApprovalResponse> {
return await fetcher("/api/auth/device-login/approve", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ user_code: userCode }),
}).then(jsonOrThrow<DeviceApprovalResponse>);
const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", {
method: "POST",
body: JSON.stringify(request),
}),
);
}
@@ -1,8 +1,13 @@
import {
authenticationCredentialToJson,
base64UrlToBuffer,
bufferToBase64Url,
parseDeviceLoginPollResponse,
parseDeviceLoginStartResponse,
parseWhoamiResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
} from "./model.ts";
declare const Deno: {
@@ -17,6 +22,21 @@ function assertEquals<T>(actual: T, expected: T): void {
}
}
function assertThrows(fn: () => unknown, message: string): void {
try {
fn();
} catch (error) {
if (
!(error instanceof Error) ||
!error.message.startsWith("Invalid auth payload:")
) {
throw new Error(`${message}: unexpected error`);
}
return;
}
throw new Error(`${message}: expected an error`);
}
function bytes(buffer: BufferSource): number[] {
if (buffer instanceof ArrayBuffer) {
return [...new Uint8Array(buffer)];
@@ -39,18 +59,20 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
const options = prepareRegistrationOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
rp: { id: "localhost", name: "Yoi" },
user: {
id: "BAUG" as unknown as BufferSource,
name: "local",
displayName: "Local User",
publicKey: {
challenge: "AQID" as unknown as BufferSource,
rp: { id: "localhost", name: "Yoi" },
user: {
id: "BAUG" as unknown as BufferSource,
name: "local",
displayName: "Local User",
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
},
});
@@ -67,11 +89,13 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
const options = prepareLoginOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
publicKey: {
challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
},
},
});
@@ -82,3 +106,85 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
9,
]);
});
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
assertEquals(parseWhoamiResponse({ actor: null }), { actor: null });
assertThrows(
() =>
parseWhoamiResponse({
actor: {
user_id: "user-1",
account_id: "account-1",
handle: "hare",
display_name: "Hare",
auth_method: "future_method",
},
}),
"unknown auth method",
);
assertThrows(
() => parseWhoamiResponse({ actor: null, token: "secret" }),
"unexpected whoami field",
);
});
Deno.test("device login rejects unsafe expiry and unknown status", () => {
const start = {
device_code: "device-1",
user_code: "ABCD-EFGH",
verification_uri: "https://yoi.example/login/device",
verification_uri_complete:
"https://yoi.example/login/device?user_code=ABCD-EFGH",
expires_in: 600,
interval: 2,
};
assertEquals(parseDeviceLoginStartResponse(start), start);
assertThrows(
() =>
parseDeviceLoginStartResponse({
...start,
expires_in: Number.MAX_SAFE_INTEGER + 1,
}),
"unsafe expiry",
);
assertThrows(
() => parseDeviceLoginPollResponse({ status: "future_status" }),
"unknown device-login status",
);
assertThrows(
() => parseDeviceLoginPollResponse({ status: "approved" }),
"approved response without token",
);
});
Deno.test("passkey credential conversion fails closed on malformed payloads", () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const registration = {
id: "AQID",
rawId: bytes,
type: "public-key",
authenticatorAttachment: "platform",
getClientExtensionResults: () => ({ credProps: { rk: true } }),
response: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal"],
},
};
const converted = registrationCredentialToJson(registration) as Record<
string,
unknown
>;
assertEquals(converted.id, "AQID");
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
assertThrows(
() => registrationCredentialToJson({ ...registration, rawId: "AQID" }),
"registration rawId string",
);
assertThrows(
() =>
authenticationCredentialToJson({ ...registration, type: "future-key" }),
"unknown credential type",
);
});
+696 -122
View File
@@ -1,72 +1,163 @@
export type AuthenticatedUser = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
import type {
ActorAuthMethod,
AuthenticatedUser,
AuthPublicConfig,
AuthUserResponse,
DeviceLoginApproveResponse,
DeviceLoginPollResponse,
DeviceLoginPollStatus,
DeviceLoginStartResponse,
LogoutResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
WhoamiResponse,
} from "$lib/generated/auth-api.ts";
export type AuthUser = AuthenticatedUser;
export type PasskeyUserResponse = AuthUserResponse;
export type DeviceApprovalResponse = DeviceLoginApproveResponse;
export type {
AuthPublicConfig,
DeviceLoginPollResponse,
DeviceLoginStartResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
WhoamiResponse,
};
export type RequestActor = AuthenticatedUser & {
auth_method: "browser_session" | "api_token" | string;
};
const MAX_AUTH_STRING_LENGTH = 16 * 1024;
const MAX_AUTH_ARRAY_LENGTH = 128;
const MAX_AUTH_OBJECT_KEYS = 128;
const MAX_AUTH_VALUE_DEPTH = 8;
const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
export type WhoamiResponse = {
actor: RequestActor | null;
};
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialCreationOptions | {
publicKey: PublicKeyCredentialCreationOptions;
};
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialRequestOptions | {
publicKey: PublicKeyCredentialRequestOptions;
};
};
export type PasskeyUserResponse = {
user: AuthenticatedUser;
};
export type DeviceApprovalResponse = {
status: string;
};
export type RegistrationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
attestationObject: string;
transports: string[];
};
};
export type AuthenticationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
};
export function base64UrlToBuffer(value: string): ArrayBuffer {
const padding = "=".repeat((4 - (value.length % 4)) % 4);
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer;
function invalid(path: string, reason: string): never {
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
}
export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalid(path, "must be an object");
}
return value as Record<string, unknown>;
}
function requireExactKeys(
value: Record<string, unknown>,
path: string,
required: readonly string[],
optional: readonly string[] = [],
): void {
const allowed = new Set([...required, ...optional]);
for (const key of required) {
if (!(key in value)) invalid(`${path}.${key}`, "is required");
}
for (const key of Object.keys(value)) {
if (!allowed.has(key)) invalid(`${path}.${key}`, "is not allowed");
}
}
function boundedString(
value: unknown,
path: string,
{ allowEmpty = false }: { allowEmpty?: boolean } = {},
): string {
if (typeof value !== "string") invalid(path, "must be a string");
if (
(!allowEmpty && value.length === 0) || value.length > MAX_AUTH_STRING_LENGTH
) {
invalid(path, "has an invalid length");
}
return value;
}
function optionalString(value: unknown, path: string): string | null {
if (value === null || value === undefined) return null;
return boundedString(value, path);
}
function positiveSafeInteger(
value: unknown,
path: string,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value <= 0 ||
value > maximum
) {
invalid(path, "must be a bounded positive safe integer");
}
return value;
}
function boundedJson(value: unknown, path: string, depth = 0): void {
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
if (value === null || typeof value === "boolean") return;
if (typeof value === "string") {
boundedString(value, path, { allowEmpty: true });
return;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) invalid(path, "must be finite");
return;
}
if (Array.isArray(value)) {
if (value.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(path, "has too many items");
}
value.forEach((item, index) =>
boundedJson(item, `${path}[${index}]`, depth + 1)
);
return;
}
const record = asRecord(value, path);
const keys = Object.keys(record);
if (keys.length > MAX_AUTH_OBJECT_KEYS) invalid(path, "has too many fields");
for (const key of keys) {
if (key.length === 0 || key.length > 256) {
invalid(path, "has an invalid field name");
}
boundedJson(record[key], `${path}.${key}`, depth + 1);
}
}
function base64Url(value: unknown, path: string): string {
const encoded = boundedString(value, path);
if (!BASE64URL_PATTERN.test(encoded)) invalid(path, "must be base64url");
return encoded;
}
function fromBase64Url(value: unknown, path: string): ArrayBuffer {
const encoded = base64Url(value, path);
try {
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(
binary,
(character) => character.charCodeAt(0),
);
if (bytes.byteLength === 0 || bytes.byteLength > MAX_AUTH_STRING_LENGTH) {
invalid(path, "decodes to an invalid length");
}
return bytes.buffer;
} catch {
invalid(path, "must be valid base64url");
}
}
function toBase64Url(value: unknown, path: string): string {
if (!(value instanceof ArrayBuffer)) invalid(path, "must be an ArrayBuffer");
if (value.byteLength === 0 || value.byteLength > MAX_AUTH_STRING_LENGTH) {
invalid(path, "has an invalid byte length");
}
const bytes = new Uint8Array(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
@@ -75,86 +166,569 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string {
);
}
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
if (value && typeof value === "object" && "publicKey" in value) {
return (value as { publicKey: T }).publicKey;
export function base64UrlToBuffer(value: string): ArrayBuffer {
return fromBase64Url(value, "base64url");
}
export function bufferToBase64Url(value: ArrayBuffer): string {
return toBase64Url(value, "buffer");
}
function parseAuthenticatorTransport(
value: unknown,
path: string,
): AuthenticatorTransport {
const transport = boundedString(value, path);
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
invalid(path, "contains an unknown authenticator transport");
}
return value as T;
return transport as AuthenticatorTransport;
}
function parseCredentialDescriptor(
value: unknown,
path: string,
): PublicKeyCredentialDescriptor {
const descriptor = asRecord(value, path);
requireExactKeys(descriptor, path, ["type", "id"], ["transports"]);
if (descriptor.type !== "public-key") {
invalid(`${path}.type`, "must be public-key");
}
const transports = descriptor.transports === undefined ? undefined : (() => {
if (!Array.isArray(descriptor.transports)) {
invalid(`${path}.transports`, "must be an array");
}
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(`${path}.transports`, "has too many items");
}
return descriptor.transports.map((transport, index) =>
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`)
);
})();
return {
type: "public-key",
id: fromBase64Url(descriptor.id, `${path}.id`),
...(transports === undefined ? {} : { transports }),
};
}
function unwrapPublicKey(
value: unknown,
path: string,
): Record<string, unknown> {
const envelope = asRecord(value, path);
if ("publicKey" in envelope) {
requireExactKeys(envelope, path, ["publicKey"]);
const publicKey = asRecord(envelope.publicKey, `${path}.publicKey`);
boundedJson(publicKey, `${path}.publicKey`);
return publicKey;
}
boundedJson(envelope, path);
return envelope;
}
function parseCreationOptions(
value: unknown,
): PublicKeyCredentialCreationOptions {
const options = unwrapPublicKey(value, "public_key");
for (
const field of ["rp", "user", "challenge", "pubKeyCredParams"] as const
) {
if (!(field in options)) {
invalid(
`public_key.publicKey.${field}`,
"is required",
);
}
}
const rp = asRecord(options.rp, "public_key.publicKey.rp");
requireExactKeys(rp, "public_key.publicKey.rp", ["name"], ["id"]);
const normalizedRp: PublicKeyCredentialRpEntity = {
name: boundedString(rp.name, "public_key.publicKey.rp.name"),
...(rp.id === undefined
? {}
: { id: boundedString(rp.id, "public_key.publicKey.rp.id") }),
};
const user = asRecord(options.user, "public_key.publicKey.user");
requireExactKeys(user, "public_key.publicKey.user", [
"id",
"name",
"displayName",
]);
const normalizedUser: PublicKeyCredentialUserEntity = {
id: fromBase64Url(user.id, "public_key.publicKey.user.id"),
name: boundedString(user.name, "public_key.publicKey.user.name"),
displayName: boundedString(
user.displayName,
"public_key.publicKey.user.displayName",
),
};
if (
!Array.isArray(options.pubKeyCredParams) ||
options.pubKeyCredParams.length === 0
) {
invalid(
"public_key.publicKey.pubKeyCredParams",
"must be a non-empty array",
);
}
if (options.pubKeyCredParams.length > MAX_AUTH_ARRAY_LENGTH) {
invalid("public_key.publicKey.pubKeyCredParams", "has too many items");
}
const pubKeyCredParams = options.pubKeyCredParams.map((value, index) => {
const parameter = asRecord(
value,
`public_key.publicKey.pubKeyCredParams[${index}]`,
);
requireExactKeys(
parameter,
`public_key.publicKey.pubKeyCredParams[${index}]`,
["type", "alg"],
);
if (parameter.type !== "public-key") {
invalid(
`public_key.publicKey.pubKeyCredParams[${index}].type`,
"must be public-key",
);
}
if (
typeof parameter.alg !== "number" || !Number.isSafeInteger(parameter.alg)
) {
invalid(
`public_key.publicKey.pubKeyCredParams[${index}].alg`,
"must be a safe integer",
);
}
return { type: "public-key" as const, alg: parameter.alg };
});
let excludeCredentials: PublicKeyCredentialDescriptor[] | undefined;
if (options.excludeCredentials !== undefined) {
if (!Array.isArray(options.excludeCredentials)) {
invalid("public_key.publicKey.excludeCredentials", "must be an array");
}
if (options.excludeCredentials.length > MAX_AUTH_ARRAY_LENGTH) {
invalid("public_key.publicKey.excludeCredentials", "has too many items");
}
excludeCredentials = options.excludeCredentials.map((descriptor, index) =>
parseCredentialDescriptor(
descriptor,
`public_key.publicKey.excludeCredentials[${index}]`,
)
);
}
const timeout = options.timeout === undefined
? undefined
: positiveSafeInteger(
options.timeout,
"public_key.publicKey.timeout",
MAX_WEBAUTHN_TIMEOUT_MS,
);
return {
...(options as unknown as PublicKeyCredentialCreationOptions),
rp: normalizedRp,
user: normalizedUser,
challenge: fromBase64Url(
options.challenge,
"public_key.publicKey.challenge",
),
pubKeyCredParams,
...(excludeCredentials === undefined ? {} : { excludeCredentials }),
...(timeout === undefined ? {} : { timeout }),
};
}
function parseRequestOptions(
value: unknown,
): PublicKeyCredentialRequestOptions {
const options = unwrapPublicKey(value, "public_key");
if (!("challenge" in options)) {
invalid("public_key.publicKey.challenge", "is required");
}
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined;
if (options.allowCredentials !== undefined) {
if (!Array.isArray(options.allowCredentials)) {
invalid("public_key.publicKey.allowCredentials", "must be an array");
}
if (options.allowCredentials.length > MAX_AUTH_ARRAY_LENGTH) {
invalid("public_key.publicKey.allowCredentials", "has too many items");
}
allowCredentials = options.allowCredentials.map((descriptor, index) =>
parseCredentialDescriptor(
descriptor,
`public_key.publicKey.allowCredentials[${index}]`,
)
);
}
const timeout = options.timeout === undefined
? undefined
: positiveSafeInteger(
options.timeout,
"public_key.publicKey.timeout",
MAX_WEBAUTHN_TIMEOUT_MS,
);
if (options.rpId !== undefined) {
boundedString(options.rpId, "public_key.publicKey.rpId");
}
return {
...(options as unknown as PublicKeyCredentialRequestOptions),
challenge: fromBase64Url(
options.challenge,
"public_key.publicKey.challenge",
),
...(allowCredentials === undefined ? {} : { allowCredentials }),
...(timeout === undefined ? {} : { timeout }),
};
}
function parseAuthenticatedUser(
value: unknown,
path: string,
): AuthenticatedUser {
const user = asRecord(value, path);
requireExactKeys(user, path, [
"user_id",
"account_id",
"handle",
"display_name",
]);
return {
user_id: boundedString(user.user_id, `${path}.user_id`),
account_id: boundedString(user.account_id, `${path}.account_id`),
handle: boundedString(user.handle, `${path}.handle`),
display_name: boundedString(user.display_name, `${path}.display_name`, {
allowEmpty: true,
}),
};
}
function parseActor(value: unknown, path: string): RequestActor {
const actor = asRecord(value, path);
requireExactKeys(actor, path, [
"user_id",
"account_id",
"handle",
"display_name",
"auth_method",
]);
const authMethod = actor.auth_method;
if (authMethod !== "browser_session" && authMethod !== "api_token") {
invalid(`${path}.auth_method`, "contains an unknown value");
}
return {
user_id: boundedString(actor.user_id, `${path}.user_id`),
account_id: boundedString(actor.account_id, `${path}.account_id`),
handle: boundedString(actor.handle, `${path}.handle`),
display_name: boundedString(actor.display_name, `${path}.display_name`, {
allowEmpty: true,
}),
auth_method: authMethod as ActorAuthMethod,
};
}
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor"]);
return {
actor: response.actor === null
? null
: parseActor(response.actor, "whoami.actor"),
};
}
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
const response = asRecord(value, "auth_config");
requireExactKeys(response, "auth_config", [
"rp_id",
"origin",
"public_base_url",
"cookie_name",
]);
const result = {
rp_id: boundedString(response.rp_id, "auth_config.rp_id"),
origin: boundedString(response.origin, "auth_config.origin"),
public_base_url: boundedString(
response.public_base_url,
"auth_config.public_base_url",
),
cookie_name: boundedString(response.cookie_name, "auth_config.cookie_name"),
};
for (
const [field, url] of [["origin", result.origin], [
"public_base_url",
result.public_base_url,
]]
) {
try {
const parsed = new URL(url);
if (
!(["http:", "https:"].includes(parsed.protocol)) || parsed.username ||
parsed.password
) {
invalid(`auth_config.${field}`, "must be a safe HTTP(S) URL");
}
} catch {
invalid(`auth_config.${field}`, "must be a valid URL");
}
}
return result;
}
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
const response = asRecord(value, "auth_user");
requireExactKeys(response, "auth_user", ["user"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user") };
}
export function prepareRegistrationOptions(
options: PasskeyRegistrationOptionsResponse,
value: unknown,
): PublicKeyCredentialCreationOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(
publicKey.challenge as unknown as string,
);
publicKey.user = {
...publicKey.user,
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
};
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((
credential,
) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
const response = asRecord(value, "registration_options");
requireExactKeys(response, "registration_options", [
"challenge_id",
"public_key",
]);
boundedString(response.challenge_id, "registration_options.challenge_id");
return parseCreationOptions(response.public_key);
}
export function prepareLoginOptions(
options: PasskeyLoginOptionsResponse,
value: unknown,
): PublicKeyCredentialRequestOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(
publicKey.challenge as unknown as string,
);
publicKey.allowCredentials = publicKey.allowCredentials?.map((
const response = asRecord(value, "login_options");
requireExactKeys(response, "login_options", ["challenge_id", "public_key"]);
boundedString(response.challenge_id, "login_options.challenge_id");
return parseRequestOptions(response.public_key);
}
function credentialCore(credential: unknown): Record<string, unknown> {
const result = asRecord(credential, "credential");
if (result.type !== "public-key") {
invalid("credential.type", "must be public-key");
}
boundedString(result.id, "credential.id");
toBase64Url(result.rawId, "credential.rawId");
if (typeof result.getClientExtensionResults !== "function") {
invalid("credential.getClientExtensionResults", "must be a function");
}
return result;
}
function clientExtensionResults(
credential: Record<string, unknown>,
): AuthenticationExtensionsClientOutputs {
const value = (credential.getClientExtensionResults as () => unknown).call(
credential,
) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
);
boundedJson(value, "credential.clientExtensionResults");
return asRecord(
value,
"credential.clientExtensionResults",
) as AuthenticationExtensionsClientOutputs;
}
export function registrationCredentialToJson(
credential: PublicKeyCredential,
): RegistrationCredentialJson {
const response = credential.response as AuthenticatorAttestationResponse;
export function registrationCredentialToJson(credential: unknown): unknown {
const core = credentialCore(credential);
const response = asRecord(core.response, "credential.response");
if (typeof response.getTransports !== "function") {
invalid("credential.response.getTransports", "must be a function");
}
const transportsValue = (response.getTransports as () => unknown).call(
response,
);
if (
!Array.isArray(transportsValue) ||
transportsValue.length > MAX_AUTH_ARRAY_LENGTH
) {
invalid("credential.response.transports", "must be a bounded array");
}
const transports = transportsValue.map((transport, index) =>
parseAuthenticatorTransport(
transport,
`credential.response.transports[${index}]`,
)
);
const authenticatorAttachment = optionalString(
core.authenticatorAttachment,
"credential.authenticatorAttachment",
);
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
id: boundedString(core.id, "credential.id"),
rawId: toBase64Url(core.rawId, "credential.rawId"),
type: "public-key",
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
transports: response.getTransports?.() ?? [],
clientDataJSON: toBase64Url(
response.clientDataJSON,
"credential.response.clientDataJSON",
),
attestationObject: toBase64Url(
response.attestationObject,
"credential.response.attestationObject",
),
transports,
},
clientExtensionResults: clientExtensionResults(core),
...(authenticatorAttachment === null ? {} : { authenticatorAttachment }),
};
}
export function authenticationCredentialToJson(
credential: PublicKeyCredential,
): AuthenticationCredentialJson {
const response = credential.response as AuthenticatorAssertionResponse;
export function authenticationCredentialToJson(credential: unknown): unknown {
const core = credentialCore(credential);
const response = asRecord(core.response, "credential.response");
const authenticatorAttachment = optionalString(
core.authenticatorAttachment,
"credential.authenticatorAttachment",
);
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
id: boundedString(core.id, "credential.id"),
rawId: toBase64Url(core.rawId, "credential.rawId"),
type: "public-key",
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
clientDataJSON: toBase64Url(
response.clientDataJSON,
"credential.response.clientDataJSON",
),
authenticatorData: toBase64Url(
response.authenticatorData,
"credential.response.authenticatorData",
),
signature: toBase64Url(
response.signature,
"credential.response.signature",
),
userHandle: response.userHandle === null
? null
: toBase64Url(response.userHandle, "credential.response.userHandle"),
},
clientExtensionResults: clientExtensionResults(core),
...(authenticatorAttachment === null ? {} : { authenticatorAttachment }),
};
}
export function isPublicKeyCredential(
credential: Credential | null,
): credential is PublicKeyCredential {
return credential != null && credential.type === "public-key";
export function parseDeviceLoginStartResponse(
value: unknown,
): DeviceLoginStartResponse {
const response = asRecord(value, "device_login_start");
requireExactKeys(response, "device_login_start", [
"device_code",
"user_code",
"verification_uri",
"verification_uri_complete",
"expires_in",
"interval",
]);
const result = {
device_code: boundedString(
response.device_code,
"device_login_start.device_code",
),
user_code: boundedString(
response.user_code,
"device_login_start.user_code",
),
verification_uri: boundedString(
response.verification_uri,
"device_login_start.verification_uri",
),
verification_uri_complete: boundedString(
response.verification_uri_complete,
"device_login_start.verification_uri_complete",
),
expires_in: positiveSafeInteger(
response.expires_in,
"device_login_start.expires_in",
MAX_DEVICE_LOGIN_SECONDS,
),
interval: positiveSafeInteger(
response.interval,
"device_login_start.interval",
60,
),
};
for (
const [field, url] of [
["verification_uri", result.verification_uri],
["verification_uri_complete", result.verification_uri_complete],
]
) {
try {
const parsed = new URL(url);
if (
!(["http:", "https:"].includes(parsed.protocol)) || parsed.username ||
parsed.password
) {
invalid(`device_login_start.${field}`, "must be a safe HTTP(S) URL");
}
} catch {
invalid(`device_login_start.${field}`, "must be a valid URL");
}
}
return result;
}
export function parseDeviceLoginPollResponse(
value: unknown,
): DeviceLoginPollResponse {
const response = asRecord(value, "device_login_poll");
requireExactKeys(response, "device_login_poll", ["status"], [
"access_token",
"token_type",
]);
const status = response.status;
if (
!["pending", "approved", "expired", "consumed"].includes(String(status))
) {
invalid("device_login_poll.status", "contains an unknown value");
}
const typedStatus = status as DeviceLoginPollStatus;
const accessToken = optionalString(
response.access_token,
"device_login_poll.access_token",
);
const tokenType = optionalString(
response.token_type,
"device_login_poll.token_type",
);
if (typedStatus === "approved") {
if (accessToken === null || tokenType !== "Bearer") {
invalid("device_login_poll", "has invalid approved token fields");
}
} else if (accessToken !== null || tokenType !== null) {
invalid("device_login_poll", "has token fields for a non-approved status");
}
return {
status: typedStatus,
...(accessToken === null ? {} : { access_token: accessToken }),
...(tokenType === null ? {} : { token_type: "Bearer" as const }),
};
}
export function parseDeviceApprovalResponse(
value: unknown,
): DeviceApprovalResponse {
const response = asRecord(value, "device_login_approval");
requireExactKeys(response, "device_login_approval", ["status", "user"]);
if (response.status !== "approved") {
invalid("device_login_approval.status", "contains an unknown value");
}
return {
status: "approved",
user: parseAuthenticatedUser(response.user, "device_login_approval.user"),
};
}
export function parseLogoutResponse(value: unknown): LogoutResponse {
const response = asRecord(value, "logout");
requireExactKeys(response, "logout", ["status"]);
if (response.status !== "logged_out") {
invalid("logout.status", "contains an unknown value");
}
return { status: "logged_out" };
}