101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import type {
|
|
PasskeyAuthenticationOptions,
|
|
PasskeyProvider,
|
|
PasskeyRegistrationOptions,
|
|
} from "../types.ts";
|
|
|
|
export type { PasskeyProvider } from "../types.ts";
|
|
|
|
export type PasskeyChallengeKind = "registration" | "authentication";
|
|
|
|
export interface PasskeyChallengeRecord {
|
|
challenge: string;
|
|
kind: PasskeyChallengeKind;
|
|
userId?: string;
|
|
rpId: string;
|
|
origin: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
/**
|
|
* Durable challenge storage contract. Production deployments with more than one
|
|
* process should provide a shared implementation (for example Redis or SQL).
|
|
*/
|
|
export interface PasskeyChallengeStore {
|
|
set(key: string, value: PasskeyChallengeRecord): Promise<void>;
|
|
consume(key: string): Promise<PasskeyChallengeRecord | undefined>;
|
|
}
|
|
|
|
export class MemoryPasskeyChallengeStore implements PasskeyChallengeStore {
|
|
private readonly values = new Map<string, PasskeyChallengeRecord>();
|
|
private readonly now: () => number;
|
|
|
|
constructor(now: () => number = () => Date.now()) {
|
|
this.now = now;
|
|
}
|
|
|
|
private pruneExpired(): void {
|
|
const timestamp = this.now();
|
|
for (const [key, value] of this.values) {
|
|
if (value.expiresAt <= timestamp) this.values.delete(key);
|
|
}
|
|
}
|
|
|
|
async set(key: string, value: PasskeyChallengeRecord): Promise<void> {
|
|
this.pruneExpired();
|
|
this.values.set(key, { ...value });
|
|
}
|
|
|
|
async consume(key: string): Promise<PasskeyChallengeRecord | undefined> {
|
|
this.pruneExpired();
|
|
const value = this.values.get(key);
|
|
this.values.delete(key);
|
|
return value ? { ...value } : undefined;
|
|
}
|
|
}
|
|
|
|
export function assertPasskeyProvider(provider: PasskeyProvider | undefined): PasskeyProvider {
|
|
if (!provider) throw new Error("WRN-AUTH-PASSKEY-PROVIDER: configure a PasskeyProvider");
|
|
return provider;
|
|
}
|
|
|
|
export function publicKeyCreationOptions(
|
|
options: PasskeyRegistrationOptions,
|
|
): PublicKeyCredentialCreationOptions {
|
|
return {
|
|
...options,
|
|
challenge: decode(options.challenge),
|
|
user: { ...options.user, id: decode(options.user.id) },
|
|
excludeCredentials: options.excludeCredentials?.map((item) => ({
|
|
...item,
|
|
id: decode(item.id),
|
|
transports: item.transports as AuthenticatorTransport[] | undefined,
|
|
})),
|
|
} as unknown as PublicKeyCredentialCreationOptions;
|
|
}
|
|
|
|
export function publicKeyRequestOptions(
|
|
options: PasskeyAuthenticationOptions,
|
|
): PublicKeyCredentialRequestOptions {
|
|
return {
|
|
...options,
|
|
challenge: decode(options.challenge),
|
|
allowCredentials: options.allowCredentials?.map((item) => ({
|
|
...item,
|
|
id: decode(item.id),
|
|
transports: item.transports as AuthenticatorTransport[] | undefined,
|
|
})),
|
|
} as unknown as PublicKeyCredentialRequestOptions;
|
|
}
|
|
|
|
function decode(value: string): Uint8Array {
|
|
if (!value || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
|
|
throw new TypeError("Passkey data must be canonical Base64URL without padding");
|
|
}
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
return bytes;
|
|
}
|