clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/fix-sync-enrollment-startup
lofi / package / runtime / auth_test.ts
auth_test.ts 12.2K
1import {2 // Package contract tests.3 authenticateDeviceCredential,4 AuthError,5 classifyCredentialOrigin,6 decryptAtRest,7 deriveAtRestKey,8 derivePrfSecret,9 encryptAtRest,10 enrollDeviceCredential,11 getAuthCapability,12} from "./auth.ts";13import { defineLofiApp } from "./app.ts";14import { assert } from "./test-assert.ts";1516defineLofiApp({17 name: "lofi-test",18 databaseName: "lofi-test",19 schema: {},20 storage: "durable",21 credentialOrigins: [],22 sync: { adapter: "jazz" },23});2425const test = (globalThis as unknown as {26 Deno: { test(name: string, body: () => void | Promise<void>): void };27}).Deno.test;2829// A base64url-decoding rawId ("AQIDBA" is bytes 1,2,3,4) shared by the fakes.30function rawIdBuffer(): ArrayBuffer {31 return new Uint8Array([1, 2, 3, 4]).buffer;32}3334// A 37-byte authenticator-data buffer (rpIdHash[32] + flags + signCount[4]).35// The WebAuthn "backup eligible" (BE) flag is bit 0x08 of byte index 32.36function authenticatorData(backupEligible: boolean): ArrayBuffer {37 const bytes = new Uint8Array(37);38 if (backupEligible) bytes[32] |= 0x08;39 return bytes.buffer;40}4142// A minimal fake CredentialsContainer whose create/get are supplied per test.43function fakeCredentials(handlers: {44 create?: (options: CredentialCreationOptions) => Promise<Credential | null>;45 get?: (options: CredentialRequestOptions) => Promise<Credential | null>;46}): CredentialsContainer {47 return {48 create: handlers.create ?? (() => Promise.resolve(null)),49 get: handlers.get ?? (() => Promise.resolve(null)),50 store: () => Promise.reject(new Error("unused")),51 preventSilentAccess: () => Promise.resolve(),52 } as unknown as CredentialsContainer;53}5455async function expectAuthError(56 operation: () => Promise<unknown>,57 code: AuthError["code"],58): Promise<void> {59 try {60 await operation();61 } catch (error) {62 assert(error instanceof AuthError, "expected an AuthError to be thrown");63 assert(64 error.code === code,65 `expected AuthError code ${code}, received ${(error as AuthError).code}`,66 );67 return;68 }69 throw new Error(`expected AuthError code ${code}, but nothing was thrown`);70}7172test("classifyCredentialOrigin treats localhost as local-only", () => {73 const report = classifyCredentialOrigin(new URL("https://localhost"), []);74 assert(report.status === "local-only", "localhost must be local-only");75 assert(report.rpId === "localhost", "the rpId must be the hostname");76});7778test("classifyCredentialOrigin blocks insecure, IP, and loopback origins", () => {79 assert(80 classifyCredentialOrigin(new URL("http://example.com"), ["example.com"]).status === "blocked",81 "non-https must be blocked",82 );83 assert(84 classifyCredentialOrigin(new URL("https://93.184.216.34"), []).status === "blocked",85 "a bare IPv4 origin must be blocked",86 );87 assert(88 classifyCredentialOrigin(new URL("https://127.0.0.1"), []).status === "local-only",89 "the loopback address is local-only, never a stable RP-ID",90 );91});9293test("classifyCredentialOrigin trusts exact and wildcard hostnames", () => {94 assert(95 classifyCredentialOrigin(new URL("https://app.example.com"), ["*.example.com"]).status ===96 "stable",97 "a host matching a *. pattern must be stable",98 );99 assert(100 classifyCredentialOrigin(new URL("https://example.com"), ["example.com"]).status === "stable",101 "an exact hostname match must be stable",102 );103});104105test("classifyCredentialOrigin leaves untrusted secure hosts unverified", () => {106 const report = classifyCredentialOrigin(new URL("https://other.example.org"), ["example.com"]);107 assert(report.status === "unverified", "a secure host not in the list must be unverified");108});109110test("enrollDeviceCredential returns a base64url DeviceCredential with an explicit rpId", async () => {111 let createCalls = 0;112 const credentials = fakeCredentials({113 create: () => {114 createCalls += 1;115 return Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential);116 },117 });118 const credential = await enrollDeviceCredential({ credentials, rpId: "app.example.com" });119 assert(createCalls === 1, "create must be invoked exactly once");120 assert(credential.rpId === "app.example.com", "the credential must carry the supplied rpId");121 // base64url of bytes [1,2,3,4] is "AQIDBA".122 assert(credential.id === "AQIDBA", "the id must be the base64url of the rawId");123});124125test("enrollDeviceCredential rejects an unstable origin as origin-rejected", async () => {126 // With no rpId, no trusted origin, and no `location` in Deno, the origin gate127 // reports "blocked", so enrollment must refuse before touching credentials.128 const credentials = fakeCredentials({129 create: () => Promise.reject(new Error("create must not be reached")),130 });131 await expectAuthError(132 () => enrollDeviceCredential({ credentials, trustedOrigins: [] }),133 "origin-rejected",134 );135});136137test("authenticateDeviceCredential returns the DeviceCredential from a get result", async () => {138 const credentials = fakeCredentials({139 get: () => Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential),140 });141 const credential = await authenticateDeviceCredential({ credentials, rpId: "app.example.com" });142 assert(credential.id === "AQIDBA", "the authenticated id must be the base64url rawId");143 assert(credential.rpId === "app.example.com", "the credential must carry the rpId");144});145146test("authenticateDeviceCredential pins the ceremony to the supplied credential id", async () => {147 let captured: PublicKeyCredentialRequestOptions | undefined;148 const credentials = fakeCredentials({149 get: (options) => {150 captured = options.publicKey;151 return Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential);152 },153 });154 const credential = await authenticateDeviceCredential({155 credentials,156 rpId: "app.example.com",157 credentialId: "AQIDBA",158 });159 assert(credential.id === "AQIDBA", "the pinned assertion must return the enrolled id");160 const allowed = captured?.allowCredentials;161 assert(allowed?.length === 1, "the request must carry exactly the enrolled credential");162 const allowedBytes = allowed[0].id as Uint8Array;163 assert(164 allowedBytes.length === 4 &&165 [1, 2, 3, 4].every((byte, index) => allowedBytes[index] === byte),166 "allowCredentials must contain the decoded enrolled credential id",167 );168});169170test("authenticateDeviceCredential rejects an assertion from a different credential", async () => {171 // The RP has other resident credentials; the guard must not accept them.172 const credentials = fakeCredentials({173 get: () =>174 Promise.resolve(175 { rawId: new Uint8Array([9, 9, 9, 9]).buffer } as unknown as Credential,176 ),177 });178 await expectAuthError(179 () =>180 authenticateDeviceCredential({181 credentials,182 rpId: "app.example.com",183 credentialId: "AQIDBA",184 }),185 "credential-mismatch",186 );187});188189test("a deployed origin outside credentialOrigins is refused, never implicitly trusted", async () => {190 (globalThis as { location?: unknown }).location = { href: "https://preview.example.com/" };191 try {192 const capability = await getAuthCapability();193 assert(194 capability.origin.status === "unverified",195 "an unlisted deployed host must report unverified, not stable",196 );197 const credentials = fakeCredentials({198 create: () => Promise.reject(new Error("create must not be reached")),199 });200 await expectAuthError(() => enrollDeviceCredential({ credentials }), "origin-rejected");201202 const pinned = await getAuthCapability({ trustedOrigins: ["preview.example.com"] });203 assert(pinned.origin.status === "stable", "a listed hostname must classify as stable");204 } finally {205 delete (globalThis as { location?: unknown }).location;206 }207});208209test("derivePrfSecret returns the PRF result bytes", async () => {210 const secret = crypto.getRandomValues(new Uint8Array(32));211 const credentials = fakeCredentials({212 get: () =>213 Promise.resolve({214 rawId: rawIdBuffer(),215 getClientExtensionResults: () => ({ prf: { results: { first: secret.buffer } } }),216 } as unknown as Credential),217 });218 const derived = await derivePrfSecret(new Uint8Array([9, 9, 9, 9]), {219 credentials,220 rpId: "app.example.com",221 });222 assert(derived.length === 32, "the derived secret must be 32 bytes");223 assert(224 derived.every((byte, index) => byte === secret[index]),225 "the derived secret must equal the PRF result bytes",226 );227});228229test("derivePrfSecret throws prf-unavailable when the client returns no PRF result", async () => {230 const credentials = fakeCredentials({231 get: () =>232 Promise.resolve({233 rawId: rawIdBuffer(),234 getClientExtensionResults: () => ({}),235 } as unknown as Credential),236 });237 await expectAuthError(238 () => derivePrfSecret(new Uint8Array([1]), { credentials, rpId: "app.example.com" }),239 "prf-unavailable",240 );241});242243test("at-rest crypto round-trips and binds ciphertext to the info string", async () => {244 const secret = crypto.getRandomValues(new Uint8Array(32));245 const key = await deriveAtRestKey(secret, "notes");246 const plaintext = new TextEncoder().encode("local-first secret");247248 const blob = await encryptAtRest(key, plaintext);249 const roundTripped = await decryptAtRest(key, blob);250 assert(251 new TextDecoder().decode(roundTripped) === "local-first secret",252 "the same key must decrypt its own ciphertext",253 );254255 // The same secret + info re-derives a key that also decrypts the prior blob.256 const sameKey = await deriveAtRestKey(secret, "notes");257 const again = await decryptAtRest(sameKey, blob);258 assert(259 new TextDecoder().decode(again) === "local-first secret",260 "the same secret+info must re-derive a working key",261 );262263 // A different info yields a different key that cannot decrypt the blob.264 const otherKey = await deriveAtRestKey(secret, "photos");265 let rejected = false;266 try {267 await decryptAtRest(otherKey, blob);268 } catch {269 rejected = true;270 }271 assert(rejected, "a key derived with a different info must fail to decrypt");272});273274test("enrollDeviceCredential maps NotAllowedError to cancelled", async () => {275 const credentials = fakeCredentials({276 create: () => Promise.reject({ name: "NotAllowedError" }),277 });278 await expectAuthError(279 () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),280 "cancelled",281 );282});283284test("enrollDeviceCredential maps SecurityError to origin-rejected", async () => {285 const credentials = fakeCredentials({286 create: () => Promise.reject({ name: "SecurityError" }),287 });288 await expectAuthError(289 () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),290 "origin-rejected",291 );292});293294test("enrollDeviceCredential names the passkey with the label and reports portable", async () => {295 let captured: PublicKeyCredentialUserEntity | undefined;296 const credentials = fakeCredentials({297 create: (options) => {298 captured = options.publicKey?.user;299 return Promise.resolve({300 rawId: rawIdBuffer(),301 response: { getAuthenticatorData: () => authenticatorData(true) },302 } as unknown as Credential);303 },304 });305 const credential = await enrollDeviceCredential({306 rpId: "app.example.com",307 label: "work account",308 credentials,309 });310 assert(captured?.name === "work account", "the label must become user.name");311 assert(captured?.displayName === "work account", "the label must become user.displayName");312 assert(credential.portable === true, "a backup-eligible credential must be portable");313});314315test("enrollDeviceCredential reports portable false without the BE flag or authenticator data", async () => {316 const clearBit = fakeCredentials({317 create: () =>318 Promise.resolve({319 rawId: rawIdBuffer(),320 response: { getAuthenticatorData: () => authenticatorData(false) },321 } as unknown as Credential),322 });323 const cleared = await enrollDeviceCredential({ rpId: "app.example.com", credentials: clearBit });324 assert(cleared.portable === false, "a device-bound credential must not be portable");325326 const noAuthData = fakeCredentials({327 create: () =>328 Promise.resolve({329 rawId: rawIdBuffer(),330 response: {},331 } as unknown as Credential),332 });333 const missing = await enrollDeviceCredential({334 rpId: "app.example.com",335 credentials: noAuthData,336 });337 assert(missing.portable === false, "an unreadable credential must default to not portable");338});