-
Notifications
You must be signed in to change notification settings - Fork 522
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* wip: Who Am I route * Resolved changes * 2 tests one still remaining * fix(www): add missing langs to analytics bento (#2214) * fix(www): add missing langs to analytics bento Also, add a copy button to the code editor for one-click code copying, removing the need for manual selection. * refactor(www): extract langs SVG icons into separate file Also, rename and move <Editor /> components to components/ui folder. * refactor(www): extract copy code snippet button to separate file * refactor(www): apply coderabbitai review comments * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: create bucket if it doesn't exist * fix: revalidate cache * docs: fix typo and remove the weird line (#2223) * docs: correct spelling of EXPRED to EXPIRED (#2228) * Added third happy test * chore: add docs, changesets, fix path * Update apps/docs/api-reference/keys/whoami.mdx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Nazar Poshtarenko <32395926+unrenamed@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: chronark <dev@chronark.com> Co-authored-by: Anne Deepa Prasanna <anneraj73@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
- Loading branch information
1 parent
00009e8
commit 09d36ad
Showing
8 changed files
with
394 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"api": minor | ||
--- | ||
|
||
add /v1/keys.whoami route |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({ | ||
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", | ||
}, | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({ | ||
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<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({ | ||
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<V1KeysWhoAmIRequest>({ | ||
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<V1KeysWhoAmIRequest, V1KeysWhoAmIResponse>({ | ||
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.