feat: release authentication laboratories 0.3.0
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user