feat: release authentication laboratories 0.3.0

This commit is contained in:
2026-08-20 00:21:31 +02:00
parent 53cc91f2a5
commit 6afa59d18f
44 changed files with 4332 additions and 66 deletions
+190
View File
@@ -0,0 +1,190 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
utf8ToBytes,
} from "../crypto/encoding";
export interface ExtensionCapabilities {
available: boolean;
values: Record<string, boolean>;
warnings: string[];
}
export interface ExtensionLabConfiguration {
credProps?: boolean;
appid?: string;
appidExclude?: string;
prf?: {
enabled: boolean;
first: string;
second?: string;
};
largeBlob?: {
registrationSupport?: "preferred" | "required";
read?: boolean;
write?: string;
};
}
type ExtensionInputsWithAppIdExclude = AuthenticationExtensionsClientInputs & {
appidExclude?: string;
};
function extensionBytes(value: string, name: string): ArrayBuffer {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${name} must not be empty.`);
const bytes = trimmed.startsWith("base64url:")
? base64UrlToBytes(trimmed.slice("base64url:".length))
: utf8ToBytes(trimmed);
if (bytes.byteLength === 0 || bytes.byteLength > 1024)
throw new Error(`${name} must contain between 1 and 1,024 bytes.`);
return bytesToArrayBuffer(bytes);
}
function appId(value: string, name: string): string {
const trimmed = value.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`${name} must be an absolute HTTPS URL.`);
}
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
throw new Error(`${name} must be an absolute HTTPS URL.`);
return parsed.href;
}
export async function detectExtensionCapabilities(): Promise<ExtensionCapabilities> {
if (typeof PublicKeyCredential === "undefined") {
return {
available: false,
values: {},
warnings: ["PublicKeyCredential is unavailable in this browser."],
};
}
const getCapabilities = PublicKeyCredential.getClientCapabilities;
if (typeof getCapabilities !== "function") {
return {
available: false,
values: {},
warnings: [
"This browser does not expose getClientCapabilities(); extension support remains unknown until a ceremony is attempted.",
],
};
}
try {
const values = await getCapabilities.call(PublicKeyCredential);
return {
available: true,
values: Object.fromEntries(
Object.entries(values).sort(([left], [right]) =>
left.localeCompare(right),
),
),
warnings: [],
};
} catch (error) {
return {
available: false,
values: {},
warnings: [
error instanceof Error
? `Capability detection failed: ${error.message}`
: "Capability detection failed.",
],
};
}
}
export function registrationExtensions(
configuration: ExtensionLabConfiguration,
): AuthenticationExtensionsClientInputs {
const extensions: ExtensionInputsWithAppIdExclude = {};
if (configuration.credProps) extensions.credProps = true;
if (configuration.appidExclude)
extensions.appidExclude = appId(configuration.appidExclude, "appidExclude");
if (configuration.prf?.enabled) {
extensions.prf = {
eval: {
first: extensionBytes(configuration.prf.first, "PRF first input"),
...(configuration.prf.second?.trim()
? {
second: extensionBytes(
configuration.prf.second,
"PRF second input",
),
}
: {}),
},
};
}
if (configuration.largeBlob?.registrationSupport)
extensions.largeBlob = {
support: configuration.largeBlob.registrationSupport,
};
return extensions;
}
export function authenticationExtensions(
configuration: ExtensionLabConfiguration,
): AuthenticationExtensionsClientInputs {
const extensions: AuthenticationExtensionsClientInputs = {};
if (configuration.appid)
extensions.appid = appId(configuration.appid, "appid");
if (configuration.prf?.enabled) {
extensions.prf = {
eval: {
first: extensionBytes(configuration.prf.first, "PRF first input"),
...(configuration.prf.second?.trim()
? {
second: extensionBytes(
configuration.prf.second,
"PRF second input",
),
}
: {}),
},
};
}
if (configuration.largeBlob?.read) extensions.largeBlob = { read: true };
if (configuration.largeBlob?.write?.trim()) {
if (configuration.largeBlob.read)
throw new Error("largeBlob read and write cannot be requested together.");
extensions.largeBlob = {
write: extensionBytes(configuration.largeBlob.write, "largeBlob value"),
};
}
return extensions;
}
export function extensionOutputToJson(value: unknown): unknown {
if (value instanceof ArrayBuffer)
return {
base64url: bytesToBase64Url(new Uint8Array(value)),
bytes: value.byteLength,
};
if (ArrayBuffer.isView(value)) {
const bytes = new Uint8Array(
value.buffer,
value.byteOffset,
value.byteLength,
);
return { base64url: bytesToBase64Url(bytes), bytes: bytes.byteLength };
}
if (Array.isArray(value)) return value.map(extensionOutputToJson);
if (value && typeof value === "object")
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
extensionOutputToJson(item),
]),
);
return value;
}
export function extensionRequestToJson(
configuration: ExtensionLabConfiguration,
): Record<string, unknown> {
return extensionOutputToJson(configuration) as Record<string, unknown>;
}