diff --git a/.changeset/late-colts-shout.md b/.changeset/late-colts-shout.md new file mode 100644 index 0000000000..a8d9104b4e --- /dev/null +++ b/.changeset/late-colts-shout.md @@ -0,0 +1,5 @@ +--- +"api": minor +--- + +add /v1/keys.whoami route diff --git a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts new file mode 100644 index 0000000000..b592e964ee --- /dev/null +++ b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts @@ -0,0 +1,34 @@ +import { newId } from "@unkey/id"; +import { KeyV1 } from "@unkey/keys"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; +import { expect, test } from "vitest"; + +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; + +test("when the key does not exist", async (t) => { + const h = await IntegrationHarness.init(t); + const apiId = newId("api"); + const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); + + const root = await h.createRootKey([`api.${apiId}.read_key`]); + + const res = await h.post({ + url: "/v1/keys.whoami", + headers: { + Authorization: `Bearer ${root.key}`, + "Content-Type": "application/json", + }, + body: { + key: key, + }, + }); + + expect(res.status).toEqual(404); + expect(res.body).toMatchObject({ + error: { + code: "NOT_FOUND", + docs: "https://unkey.dev/docs/api-reference/errors/code/NOT_FOUND", + message: "Key not found", + }, + }); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts new file mode 100644 index 0000000000..b38c1c1247 --- /dev/null +++ b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts @@ -0,0 +1,86 @@ +import { schema } from "@unkey/db"; +import { sha256 } from "@unkey/hash"; +import { newId } from "@unkey/id"; +import { KeyV1 } from "@unkey/keys"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; + +import { randomUUID } from "node:crypto"; +import { expect, test } from "vitest"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; + +test("returns 200", async (t) => { + const h = await IntegrationHarness.init(t); + const root = await h.createRootKey(["api.*.read_key"]); + + const key = new KeyV1({ byteLength: 16 }).toString(); + const hash = await sha256(key); + const meta = JSON.stringify({ hello: "world" }); + + const keySchema = { + id: newId("test"), + keyAuthId: h.resources.userKeyAuth.id, + workspaceId: h.resources.userWorkspace.id, + start: "test", + name: "test", + remaining: 100, + enabled: true, + environment: "test", + hash: hash, + meta: meta, + createdAt: new Date(), + }; + await h.db.primary.insert(schema.keys).values(keySchema); + + const res = await h.post({ + url: "/v1/keys.whoami", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + expect(res.body.id).toEqual(keySchema.id); + expect(res.body.name).toEqual(keySchema.name); + expect(res.body.remaining).toEqual(keySchema.remaining); + expect(res.body.name).toEqual(keySchema.name); + expect(res.body.meta).toEqual(JSON.parse(keySchema.meta)); + expect(res.body.enabled).toEqual(keySchema.enabled); + expect(res.body.environment).toEqual(keySchema.environment); +}); + +test("returns identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const identity = { + id: newId("identity"), + externalId: randomUUID(), + workspaceId: h.resources.userWorkspace.id, + }; + await h.db.primary.insert(schema.identities).values(identity); + + const { key } = await h.createKey({ identityId: identity.id }); + const root = await h.createRootKey([ + `api.${h.resources.userApi.id}.read_api`, + `api.${h.resources.userApi.id}.read_key`, + ]); + + const res = await h.post({ + url: "/v1/keys.whoami", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + expect(res.body.identity).toBeDefined(); + expect(res.body.identity!.id).toEqual(identity.id); + expect(res.body.identity!.externalId).toEqual(identity.externalId); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts new file mode 100644 index 0000000000..7725c70a14 --- /dev/null +++ b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { runCommonRouteTests } from "@/pkg/testutil/common-tests"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; + +import { describe, expect, test } from "vitest"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; + +runCommonRouteTests({ + prepareRequest: async (h) => { + const { key } = await h.createKey(); + return { + method: "POST", + url: "/v1/keys.whoami", + headers: { + "Content-Type": "application/json", + }, + body: { + key: key, + }, + }; + }, +}); + +describe("correct permissions", () => { + describe.each([ + { name: "legacy", roles: ["*"] }, + { name: "legacy and more", roles: ["*", randomUUID()] }, + { name: "wildcard api", roles: ["api.*.read_key", "api.*.read_api"] }, + { + name: "wildcard mixed", + roles: ["api.*.read_key", (apiId: string) => `api.${apiId}.read_api`], + }, + { + name: "wildcard mixed 2", + roles: ["api.*.read_api", (apiId: string) => `api.${apiId}.read_key`], + }, + { name: "wildcard and more", roles: ["api.*.read_key", "api.*.read_api", randomUUID()] }, + { + name: "specific apiId", + roles: [ + (apiId: string) => `api.${apiId}.read_key`, + (apiId: string) => `api.${apiId}.read_api`, + ], + }, + { + name: "specific apiId and more", + roles: [ + (apiId: string) => `api.${apiId}.read_key`, + (apiId: string) => `api.${apiId}.read_api`, + randomUUID(), + ], + }, + ])("$name", ({ roles }) => { + test("returns 200", async (t) => { + const h = await IntegrationHarness.init(t); + const { key } = await h.createKey(); + + const root = await h.createRootKey( + roles.map((role) => (typeof role === "string" ? role : role(h.resources.userApi.id))), + ); + + const res = await h.post({ + url: "/v1/keys.whoami", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toEqual(200); + }); + }); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.ts b/apps/api/src/routes/v1_keys_whoAmI.ts new file mode 100644 index 0000000000..a32b4172d4 --- /dev/null +++ b/apps/api/src/routes/v1_keys_whoAmI.ts @@ -0,0 +1,174 @@ +import type { App } from "@/pkg/hono/app"; +import { createRoute, z } from "@hono/zod-openapi"; + +import { rootKeyAuth } from "@/pkg/auth/root_key"; +import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; +import { sha256 } from "@unkey/hash"; +import { buildUnkeyQuery } from "@unkey/rbac"; + +const route = createRoute({ + tags: ["keys"], + operationId: "whoami", + method: "post", + path: "/v1/keys.whoami", + security: [{ bearerAuth: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + key: z.string().min(1).openapi({ + description: "The actual key to fetch", + example: "sk_123", + }), + }), + }, + }, + }, + }, + + responses: { + 200: { + description: "The configuration for a single key", + content: { + "application/json": { + schema: z.object({ + id: z.string().openapi({ + description: "The ID of the key", + example: "key_123", + }), + name: z.string().optional().openapi({ + description: "The name of the key", + example: "API Key 1", + }), + remaining: z.number().int().optional().openapi({ + description: "The remaining number of requests for the key", + example: 1000, + }), + identity: z + .object({ + id: z.string().openapi({ + description: "The identity ID associated with the key", + example: "id_123", + }), + externalId: z.string().openapi({ + description: "The external identity ID associated with the key", + example: "ext123", + }), + }) + .optional() + .openapi({ + description: "The identity object associated with the key", + }), + meta: z + .record(z.unknown()) + .optional() + .openapi({ + description: "Metadata associated with the key", + example: { role: "admin", plan: "premium" }, + }), + createdAt: z.number().int().openapi({ + description: "The timestamp in milliseconds when the key was created", + example: 1620000000000, + }), + enabled: z.boolean().openapi({ + description: "Whether the key is enabled", + example: true, + }), + environment: z.string().optional().openapi({ + description: "The environment the key is associated with", + example: "production", + }), + }), + }, + }, + }, + ...openApiErrorResponses, + }, +}); + +export type Route = typeof route; +export type V1KeysWhoAmIRequest = z.infer< + (typeof route.request.body.content)["application/json"]["schema"] +>; +export type V1KeysWhoAmIResponse = z.infer< + (typeof route.responses)[200]["content"]["application/json"]["schema"] +>; + +export const registerV1KeysWhoAmI = (app: App) => + app.openapi(route, async (c) => { + const { key: secret } = c.req.valid("json"); + const { cache, db } = c.get("services"); + const hash = await sha256(secret); + const { val: data, err } = await cache.keyByHash.swr(hash, async () => { + const dbRes = await db.readonly.query.keys.findFirst({ + where: (table, { eq, and, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAt)), + with: { + keyAuth: { + with: { + api: true, + }, + }, + identity: true, + }, + }); + + if (!dbRes) { + return null; + } + + return { + key: { + ...dbRes, + }, + api: dbRes.keyAuth.api, + identity: dbRes.identity, + } as any; // this was necessary so that we don't need to return the workspace and other types defined in keyByHash + }); + + if (err) { + throw new UnkeyApiError({ + code: "INTERNAL_SERVER_ERROR", + message: `unable to load key: ${err.message}`, + }); + } + if (!data) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: "Key not found", + }); + } + const { api, key } = data; + const auth = await rootKeyAuth( + c, + buildUnkeyQuery(({ or }) => or("*", "api.*.read_key", `api.${api.id}.read_key`)), + ); + + if (key.workspaceId !== auth.authorizedWorkspaceId) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: "Key not found", + }); + } + let meta = key.meta ? JSON.parse(key.meta) : undefined; + if (!meta || Object.keys(meta).length === 0) { + meta = undefined; + } + + return c.json({ + id: key.id, + name: key.name ?? undefined, + remaining: key.remaining ?? undefined, + identity: data.identity + ? { + id: data.identity.id, + externalId: data.identity.externalId, + } + : undefined, + meta: meta, + createdAt: key.createdAt.getTime(), + enabled: key.enabled, + environment: key.environment ?? undefined, + }); + }); diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 6a15a707f6..913cc4bfc5 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -13,6 +13,7 @@ import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerification import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey"; import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining"; import { registerV1KeysVerifyKey } from "./routes/v1_keys_verifyKey"; +import { registerV1KeysWhoAmI } from "./routes/v1_keys_whoami"; import { registerV1Liveness } from "./routes/v1_liveness"; import { registerV1RatelimitLimit } from "./routes/v1_ratelimit_limit"; @@ -67,6 +68,7 @@ registerV1Liveness(app); // keys registerV1KeysGetKey(app); +registerV1KeysWhoAmI(app); registerV1KeysDeleteKey(app); registerV1KeysCreateKey(app); registerV1KeysVerifyKey(app); diff --git a/apps/docs/api-reference/keys/whoami.mdx b/apps/docs/api-reference/keys/whoami.mdx new file mode 100644 index 0000000000..cc45d0f8ee --- /dev/null +++ b/apps/docs/api-reference/keys/whoami.mdx @@ -0,0 +1,15 @@ +--- +title: Returns data about a key +openapi: post /v1/keys.whoami +--- + +## Changelog + +| Date | Changes | +|-------------|-----------------------------| +| Oct 07 2024 | Introduced endpoint | + + +You may not always have easy access to the `keyId` and therefore can't use [`/v1/keys.getKey`](/api-reference/keys/get). +This offers an escape hatch to send us the real key instead. + diff --git a/apps/docs/mint.json b/apps/docs/mint.json index bb51ddea24..d29f8ea1e7 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -234,7 +234,8 @@ "api-reference/keys/set-permissions", "api-reference/keys/add-roles", "api-reference/keys/remove-roles", - "api-reference/keys/set-roles" + "api-reference/keys/set-roles", + "api-reference/keys/whoami" ] }, {