From e42ab1b6fdca23e6c463f09789c93ad662e60df3 Mon Sep 17 00:00:00 2001 From: devcool20 Date: Sun, 17 May 2026 12:55:54 +0530 Subject: [PATCH 1/4] test(auth-unkey): cover fail-closed verification --- packages/auth-unkey/src/index.ts | 23 +++- packages/auth-unkey/test/index.test.ts | 144 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/packages/auth-unkey/src/index.ts b/packages/auth-unkey/src/index.ts index a9d62894..54886f87 100644 --- a/packages/auth-unkey/src/index.ts +++ b/packages/auth-unkey/src/index.ts @@ -180,8 +180,23 @@ const buildDefaultIdentity = ( }; }; -const isValidResult = (result: UnkeyVerifyResultShape): boolean => - asRecord(result.data)?.valid === true; +const isValidResult = ( + result: UnkeyVerifyResultShape | undefined +): result is UnkeyVerifyResultShape => + result !== undefined && asRecord(result.data)?.valid === true; + +const verifyUnkeyToken = async ( + client: UnkeyBearerResolverClient, + verifyInput: + | { readonly key: string } + | { readonly key: string; readonly permissions: string } +): Promise => { + try { + return (await client.keys.verifyKey(verifyInput)) as UnkeyVerifyResultShape; + } catch { + return undefined; + } +}; /** * Creates a DSAR-compatible bearer-token resolver backed by Unkey key @@ -199,9 +214,7 @@ export const makeUnkeyBearerResolver = (config: UnkeyBearerResolverConfig) => { const verifyInput = config.permissions ? { key: input.token, permissions: config.permissions } : { key: input.token }; - const result = (await client.keys.verifyKey( - verifyInput - )) as UnkeyVerifyResultShape; + const result = await verifyUnkeyToken(client, verifyInput); if (!isValidResult(result)) { return undefined; } diff --git a/packages/auth-unkey/test/index.test.ts b/packages/auth-unkey/test/index.test.ts index 353cedad..ffd419c9 100644 --- a/packages/auth-unkey/test/index.test.ts +++ b/packages/auth-unkey/test/index.test.ts @@ -5,6 +5,34 @@ import type { DsarResolvedIdentity } from "#src"; const DSAR_UNKEY_REQUIRED_PERMISSION = "dsar.api"; +const INVALID_VERIFY_RESULTS = [ + { + code: "EXPIRED", + name: "expired keys", + }, + { + code: "REVOKED", + name: "revoked keys", + }, + { + code: "MALFORMED", + name: "malformed keys", + }, + { + code: "RATE_LIMITED", + name: "rate-limited keys", + }, +] as const; + +const MALFORMED_TOKEN_CASES = [ + "", + " ", + "short", + "not-an-unkey-token", + "sk_invalid whitespace", + "sk_invalid_!", +] as const; + const verifyAdminKey = () => Promise.resolve({ data: { @@ -47,6 +75,21 @@ const verifyInvalidKey = () => }, }); +const createInvalidVerifyKey = + (code: string) => (_input: { readonly key: string }) => + Promise.resolve({ + data: { + code, + valid: false, + }, + }); + +const createRecordingInvalidVerifyKey = + (inputs: string[]) => (input: { readonly key: string }) => { + inputs.push(input.key); + return verifyInvalidKey(); + }; + const createPermissionAwareVerifyKey = ( inputs: { @@ -88,6 +131,21 @@ const verifySubjectKey = () => }, }); +const verifyTenantTwoKey = () => + Promise.resolve({ + data: { + identity: { + externalId: "tenant-two-admin", + }, + keyId: "key_456", + meta: { + role: "admin", + tenantId: "tenant-2", + }, + valid: true, + }, + }); + const mapVerifiedSubjectIdentity = ({ defaultIdentity, }: { @@ -275,3 +333,89 @@ describe("makeUnkeyBearerResolver", () => { }); }); }); + +describe("makeUnkeyBearerResolver fail-closed coverage", () => { + it.each(INVALID_VERIFY_RESULTS)( + "returns undefined for $name", + async ({ code }) => { + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: createInvalidVerifyKey(code), + }, + }, + }); + + await expect( + resolver({ + request: new Request("https://example.test"), + token: "token-1", + }) + ).resolves.toBeUndefined(); + } + ); + + it("returns undefined when Unkey verification fails before returning a result", async () => { + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: () => + Promise.reject(new Error("Unkey verification unavailable.")), + }, + }, + }); + + await expect( + resolver({ + request: new Request("https://example.test"), + token: "token-1", + }) + ).resolves.toBeUndefined(); + }); + + it.each(MALFORMED_TOKEN_CASES)( + "delegates malformed-looking token %p to Unkey and fails closed", + async (token) => { + const verifyInputs: string[] = []; + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: createRecordingInvalidVerifyKey(verifyInputs), + }, + }, + }); + + await expect( + resolver({ + request: new Request("https://example.test"), + token, + }) + ).resolves.toBeUndefined(); + + expect(verifyInputs).toStrictEqual([token]); + } + ); + + it("preserves tenant metadata for downstream tenant-scoped authorization", async () => { + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: verifyTenantTwoKey, + }, + }, + fallbackPrincipalKind: "operator", + }); + + await expect( + resolver({ + request: new Request("https://example.test"), + token: "token-1", + }) + ).resolves.toStrictEqual({ + actorId: "tenant-two-admin", + principalKind: "operator", + role: "admin", + tenantId: "tenant-2", + }); + }); +}); From 7f5864215408bc8c9709525dde1371b3b937811f Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Thu, 28 May 2026 10:23:33 +0100 Subject: [PATCH 2/4] Add auth-unkey tenant mismatch coverage --- packages/auth-unkey/test/index.test.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/auth-unkey/test/index.test.ts b/packages/auth-unkey/test/index.test.ts index ffd419c9..745b8555 100644 --- a/packages/auth-unkey/test/index.test.ts +++ b/packages/auth-unkey/test/index.test.ts @@ -162,6 +162,20 @@ const mapVerifiedSubjectIdentity = ({ }; }; +const mapRequestedTenantIdentity = ({ + defaultIdentity, + request, +}: { + readonly defaultIdentity: DsarResolvedIdentity | null; + readonly request: Request; +}) => { + const requestedTenantId = request.headers.get("x-requested-tenant-id"); + if (defaultIdentity?.tenantId !== requestedTenantId) { + return null; + } + return defaultIdentity; +}; + describe("makeUnkeyBearerResolver", () => { it("maps Unkey verification metadata into a DSAR identity", async () => { const resolver = makeUnkeyBearerResolver({ @@ -418,4 +432,26 @@ describe("makeUnkeyBearerResolver fail-closed coverage", () => { tenantId: "tenant-2", }); }); + + it("lets tenant-scoped hosts reject keys for a different requested tenant", async () => { + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: verifyAdminKey, + }, + }, + mapIdentity: mapRequestedTenantIdentity, + }); + + await expect( + resolver({ + request: new Request("https://example.test", { + headers: { + "x-requested-tenant-id": "tenant-2", + }, + }), + token: "tenant-a-token", + }) + ).resolves.toBeUndefined(); + }); }); From 7368f0dfaab44311717a77aeac2fe22dabc0e824 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:57:47 +0100 Subject: [PATCH 3/4] Add fail-closed resolver changeset --- .changeset/auth-unkey-fail-closed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/auth-unkey-fail-closed.md diff --git a/.changeset/auth-unkey-fail-closed.md b/.changeset/auth-unkey-fail-closed.md new file mode 100644 index 00000000..82b97f06 --- /dev/null +++ b/.changeset/auth-unkey-fail-closed.md @@ -0,0 +1,5 @@ +--- +"dsar": patch +--- + +Fail closed in the Unkey bearer resolver when key verification throws, treating provider errors and unreachable Unkey hosts as unauthenticated instead of surfacing provider exceptions. From 23d1d614ca2c84c856b230184b839ea3691c0855 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:02:25 +0100 Subject: [PATCH 4/4] Add onVerifyError hook for thrown Unkey verification failures --- .changeset/auth-unkey-fail-closed.md | 4 ++-- packages/auth-unkey/src/index.ts | 12 +++++++++--- packages/auth-unkey/src/types.ts | 8 ++++++++ packages/auth-unkey/test/index.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.changeset/auth-unkey-fail-closed.md b/.changeset/auth-unkey-fail-closed.md index 82b97f06..583504f5 100644 --- a/.changeset/auth-unkey-fail-closed.md +++ b/.changeset/auth-unkey-fail-closed.md @@ -1,5 +1,5 @@ --- -"dsar": patch +"dsar": minor --- -Fail closed in the Unkey bearer resolver when key verification throws, treating provider errors and unreachable Unkey hosts as unauthenticated instead of surfacing provider exceptions. +Fail closed in the Unkey bearer resolver when key verification throws, treating provider errors and unreachable Unkey hosts as unauthenticated instead of surfacing provider exceptions, and add an optional `onVerifyError` hook so hosts can log or emit metrics for thrown verification failures. diff --git a/packages/auth-unkey/src/index.ts b/packages/auth-unkey/src/index.ts index 54886f87..898ff3f9 100644 --- a/packages/auth-unkey/src/index.ts +++ b/packages/auth-unkey/src/index.ts @@ -189,11 +189,13 @@ const verifyUnkeyToken = async ( client: UnkeyBearerResolverClient, verifyInput: | { readonly key: string } - | { readonly key: string; readonly permissions: string } + | { readonly key: string; readonly permissions: string }, + onVerifyError?: (error: unknown) => void ): Promise => { try { return (await client.keys.verifyKey(verifyInput)) as UnkeyVerifyResultShape; - } catch { + } catch (error) { + onVerifyError?.(error); return undefined; } }; @@ -214,7 +216,11 @@ export const makeUnkeyBearerResolver = (config: UnkeyBearerResolverConfig) => { const verifyInput = config.permissions ? { key: input.token, permissions: config.permissions } : { key: input.token }; - const result = await verifyUnkeyToken(client, verifyInput); + const result = await verifyUnkeyToken( + client, + verifyInput, + config.onVerifyError + ); if (!isValidResult(result)) { return undefined; } diff --git a/packages/auth-unkey/src/types.ts b/packages/auth-unkey/src/types.ts index fa480f90..bb3c7a58 100644 --- a/packages/auth-unkey/src/types.ts +++ b/packages/auth-unkey/src/types.ts @@ -51,6 +51,14 @@ export interface UnkeyBearerResolverConfig { readonly client?: UnkeyBearerResolverClient; /** Optional permission expression required during key verification. */ readonly permissions?: string; + /** + * Optional observer invoked when Unkey key verification throws. + * + * The resolver still fails closed (the request resolves as + * unauthenticated), but this hook lets hosts log or emit metrics so + * provider outages and misconfiguration stay diagnosable. + */ + readonly onVerifyError?: (error: unknown) => void; /** Default principal kind when Unkey metadata does not provide one. */ readonly fallbackPrincipalKind?: DsarPrincipalKind; /** Default role when Unkey metadata and roles do not provide one. */ diff --git a/packages/auth-unkey/test/index.test.ts b/packages/auth-unkey/test/index.test.ts index 745b8555..b3086033 100644 --- a/packages/auth-unkey/test/index.test.ts +++ b/packages/auth-unkey/test/index.test.ts @@ -387,6 +387,30 @@ describe("makeUnkeyBearerResolver fail-closed coverage", () => { ).resolves.toBeUndefined(); }); + it("reports thrown verification failures to onVerifyError while failing closed", async () => { + const observedErrors: unknown[] = []; + const verifyError = new Error("Unkey verification unavailable."); + const resolver = makeUnkeyBearerResolver({ + client: { + keys: { + verifyKey: () => Promise.reject(verifyError), + }, + }, + onVerifyError: (error) => { + observedErrors.push(error); + }, + }); + + await expect( + resolver({ + request: new Request("https://example.test"), + token: "token-1", + }) + ).resolves.toBeUndefined(); + + expect(observedErrors).toStrictEqual([verifyError]); + }); + it.each(MALFORMED_TOKEN_CASES)( "delegates malformed-looking token %p to Unkey and fails closed", async (token) => {