diff --git a/.changeset/auth-unkey-fail-closed.md b/.changeset/auth-unkey-fail-closed.md new file mode 100644 index 00000000..583504f5 --- /dev/null +++ b/.changeset/auth-unkey-fail-closed.md @@ -0,0 +1,5 @@ +--- +"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, 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 a9d62894..898ff3f9 100644 --- a/packages/auth-unkey/src/index.ts +++ b/packages/auth-unkey/src/index.ts @@ -180,8 +180,25 @@ 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 }, + onVerifyError?: (error: unknown) => void +): Promise => { + try { + return (await client.keys.verifyKey(verifyInput)) as UnkeyVerifyResultShape; + } catch (error) { + onVerifyError?.(error); + return undefined; + } +}; /** * Creates a DSAR-compatible bearer-token resolver backed by Unkey key @@ -199,9 +216,11 @@ 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, + 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 353cedad..b3086033 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, }: { @@ -104,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({ @@ -275,3 +347,135 @@ 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("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) => { + 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", + }); + }); + + 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(); + }); +});