191 lines
5.4 KiB
TypeScript
191 lines
5.4 KiB
TypeScript
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>;
|
|
}
|