release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user