Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auth-unkey-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 24 additions & 5 deletions packages/auth-unkey/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UnkeyVerifyResultShape | undefined> => {
try {
return (await client.keys.verifyKey(verifyInput)) as UnkeyVerifyResultShape;
} catch (error) {
onVerifyError?.(error);
return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

/**
* Creates a DSAR-compatible bearer-token resolver backed by Unkey key
Expand All @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/auth-unkey/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
204 changes: 204 additions & 0 deletions packages/auth-unkey/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
}: {
Expand All @@ -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({
Expand Down Expand Up @@ -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();
});
});
Loading