From 7c53fbe3288fabae9bbb4526ea8ac71c7945e296 Mon Sep 17 00:00:00 2001 From: moritz Date: Tue, 8 Apr 2025 16:31:16 -0700 Subject: [PATCH] Implement endpint and add testst --- .../credential-scanning/revoke/route.tsx | 192 +++++++++++ apps/backend/src/lib/emails.tsx | 25 +- apps/e2e/tests/backend/backend-helpers.ts | 90 ++++- .../credential-scanning/revoke.test.ts | 318 ++++++++++++++++++ packages/stack-shared/src/known-errors.tsx | 10 + 5 files changed, 623 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts diff --git a/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx new file mode 100644 index 000000000..8998997b4 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx @@ -0,0 +1,192 @@ +import { getSharedEmailConfig, sendEmail } from "@/lib/emails"; +import { listUserTeamPermissions } from "@/lib/permissions"; +import { getTenancy } from "@/lib/tenancies"; +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +// Helper function to determine if a port is secure +function isSecureEmailPort(port: number | string): boolean { + const portNum = typeof port === 'string' ? parseInt(port) : port; + return portNum === 465; +} + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Revoke an API key", + description: "Revoke an API key that was found through credential scanning", + tags: ["Credential Scanning"], + }, + request: yupObject({ + body: yupObject({ + api_key: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + async handler({ body }) { + + + // Get the API key and revoke it. We use a transaction to ensure we do not send emails multiple times. + const updatedApiKey = await prismaClient.$transaction(async (tx) => { + // Find the API key in the database + const apiKey = await tx.projectApiKey.findUnique({ + where: { + secretApiKey: body.api_key, + } + }); + + if (!apiKey) { + throw new KnownErrors.ApiKeyNotFound(); + } + + if (apiKey.isPublic) { + throw new KnownErrors.PublicApiKeyCannotBeRevoked(); + } + + if (apiKey.manuallyRevokedAt) { + return null; + } + + // Revoke the API key + await tx.projectApiKey.update({ + where: { + tenancyId_id: { + tenancyId: apiKey.tenancyId, + id: apiKey.id, + }, + }, + data: { + manuallyRevokedAt: new Date(), + }, + }); + + return apiKey; + }); + + if (!updatedApiKey) { + return { + statusCode: 200, + bodyType: "success", + }; + } + + // Get affected users and their emails + const affectedEmails = new Set(); + + + if (updatedApiKey.projectUserId) { + // For user API keys, notify the user + + const projectUser = await prismaClient.projectUser.findUnique({ + where: { + tenancyId_projectUserId: { + tenancyId: updatedApiKey.tenancyId, + projectUserId: updatedApiKey.projectUserId, + }, + }, + include: { + contactChannels: true, + }, + }); + + if (!projectUser) { + // This should never happen + throw new StackAssertionError("Project user not found"); + } + // We might have other types besides email, so we disable this rule + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const primaryEmail = projectUser.contactChannels.find(c => c.type === 'EMAIL' && c.isPrimary)?.value ?? undefined; + if (primaryEmail) { + affectedEmails.add(primaryEmail); + } + } else if (updatedApiKey.teamId) { + // For team API keys, notify users with manage_api_keys permission + + const userIdsWithManageApiKeysPermission = await prismaClient.$transaction(async (tx) => { + const tenancy = await getTenancy(updatedApiKey.tenancyId); + + if (!tenancy) { + throw new StackAssertionError("Tenancy not found"); + } + + if (!updatedApiKey.teamId) { + throw new StackAssertionError("Team ID not specified in team API key"); + } + + const permissions = await listUserTeamPermissions(tx, { + tenancy, + teamId: updatedApiKey.teamId, + permissionId: '$manage_api_keys', + recursive: true, + }); + + return permissions.map(p => p.user_id); + }); + + + const usersWithManageApiKeysPermission = await prismaClient.projectUser.findMany({ + where: { + tenancyId: updatedApiKey.tenancyId, + projectUserId: { + in: userIdsWithManageApiKeysPermission, + }, + }, + include: { + contactChannels: true, + }, + }); + + for (const user of usersWithManageApiKeysPermission) { + // We might have other types besides email, so we disable this rule + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const primaryEmail = user.contactChannels.find(c => c.type === 'EMAIL' && c.isPrimary)?.value ?? undefined; + if (primaryEmail) { + affectedEmails.add(primaryEmail); + } + } + } + + + // Create email content + const subject = `API Key Revoked: ${updatedApiKey.description}`; + const htmlContent = ` +
+

API Key Revoked

+

+ Your API key "${updatedApiKey.description}" has been automatically revoked because it was found in a public repository. +

+

+ This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support. +

+

+ Please create a new API key if needed. +

+
+ `; + + + const emailConfig = await getSharedEmailConfig("Stack Auth"); + + + // Send email notifications + for (const email of affectedEmails) { + await sendEmail({ + tenancyId: updatedApiKey.tenancyId, + emailConfig, + to: email, + subject, + html: htmlContent, + }); + } + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index e11424a68..01829a122 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -300,16 +300,7 @@ async function getEmailConfig(tenancy: Tenancy): Promise { const projectEmailConfig = tenancy.config.email_config; if (projectEmailConfig.type === 'shared') { - return { - host: getEnvVariable('STACK_EMAIL_HOST'), - port: parseInt(getEnvVariable('STACK_EMAIL_PORT')), - username: getEnvVariable('STACK_EMAIL_USERNAME'), - password: getEnvVariable('STACK_EMAIL_PASSWORD'), - senderEmail: getEnvVariable('STACK_EMAIL_SENDER'), - senderName: tenancy.project.display_name, - secure: isSecureEmailPort(getEnvVariable('STACK_EMAIL_PORT')), - type: 'shared', - }; + return await getSharedEmailConfig(tenancy.project.display_name); } else { if (!projectEmailConfig.host || !projectEmailConfig.port || !projectEmailConfig.username || !projectEmailConfig.password || !projectEmailConfig.sender_email || !projectEmailConfig.sender_name) { throw new StackAssertionError("Email config is not complete despite not being shared. This should never happen?", { projectId: tenancy.id, emailConfig: projectEmailConfig }); @@ -326,3 +317,17 @@ async function getEmailConfig(tenancy: Tenancy): Promise { }; } } + + +export async function getSharedEmailConfig(displayName: string): Promise { + return { + host: getEnvVariable('STACK_EMAIL_HOST'), + port: parseInt(getEnvVariable('STACK_EMAIL_PORT')), + username: getEnvVariable('STACK_EMAIL_USERNAME'), + password: getEnvVariable('STACK_EMAIL_PASSWORD'), + senderEmail: getEnvVariable('STACK_EMAIL_SENDER'), + senderName: displayName, + secure: isSecureEmailPort(getEnvVariable('STACK_EMAIL_PORT')), + type: 'shared', + }; +} diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index ca1bf492b..045a45c5c 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -882,9 +882,20 @@ export namespace ProjectApiKey { api_key: apiKey, }, }); - expect(response.status).oneOf([200, 404]); + expect(response.status).oneOf([200, 401, 404]); return response.body; } + + export async function revoke(apiKeyId: string) { + const response = await niceBackendFetch(`/api/v1/user-api-keys/${apiKeyId}`, { + method: "PATCH", + accessType: "server", + body: { + revoked: true, + }, + }); + return response; + } } export namespace Team { @@ -908,9 +919,21 @@ export namespace ProjectApiKey { api_key: apiKey, }, }); - expect(response.status).oneOf([200, 404]); + expect(response.status).oneOf([200, 401, 404]); return response.body; } + + + export async function revoke(apiKeyId: string) { + const response = await niceBackendFetch(`/api/v1/team-api-keys/${apiKeyId}`, { + method: "PATCH", + accessType: "server", + body: { + revoked: true, + }, + }); + return response; + } } } @@ -1096,6 +1119,15 @@ export namespace Team { `); } + export async function addPermission(teamId: string, userId: string, permissionId: string) { + const response = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${userId}/${permissionId}`, { + method: "POST", + accessType: "server", + body: {}, + }); + return response; + } + export async function sendInvitation(mail: string | Mailbox, teamId: string) { const response = await niceBackendFetch("/api/v1/team-invitations/send-code", { method: "POST", @@ -1147,6 +1179,60 @@ export namespace Team { } } +export namespace User { + export function setBackendContextFromUser({ mailbox, accessToken, refreshToken }: {mailbox: Mailbox, accessToken: string, refreshToken: string}) { + backendContext.set({ + mailbox, + userAuth: { + accessToken, + refreshToken, + }, + }); + } + + + export async function create({ emailAddress }: {emailAddress?: string} = {}) { + // Create new mailbox + const email = emailAddress ?? `unindexed-mailbox--${randomUUID()}${generatedEmailSuffix}`; + const mailbox = createMailbox(email); + const password = generateSecureRandomString(); + const createUserResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email, + password, + verification_callback_url: "http://localhost:12345/some-callback-url", + }, + }); + expect(createUserResponse).toMatchObject({ + status: 200, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + user_id: expect.any(String), + }, + headers: expect.anything(), + }); + return { + userId: createUserResponse.body.user_id, + mailbox, + accessToken: createUserResponse.body.access_token, + refreshToken: createUserResponse.body.refresh_token, + }; + } + + export async function createMultiple(count: number) { + const users = []; + for (let i = 0; i < count; i++) { + const user = await User.create({}); + users.push(user); + } + return users; + } +} + + export namespace Webhook { export async function createProjectWithEndpoint() { const { projectId } = await Project.createAndSwitch({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts new file mode 100644 index 000000000..0f10d0bc0 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts @@ -0,0 +1,318 @@ +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import type { MailboxMessage } from "../../../../../../helpers"; +import { it } from "../../../../../../helpers"; +import { Project, ProjectApiKey, Team, User, niceBackendFetch } from "../../../../../backend-helpers"; + +it("should send email notification to user when revoking an API key through credential scanning", async ({ expect }: { expect: any }) => { + + await Project.createAndSwitch({ config: { magic_link_enabled: true, allow_team_api_keys: true, allow_user_api_keys: true } }); + + const [user1, user2] = await User.createMultiple(2); + + + User.setBackendContextFromUser(user1); + + // Create a user API key + const { createUserApiKeyResponse } = await ProjectApiKey.User.create({ + user_id: user1.userId, + description: "Test API Key to Revoke", + expires_at_millis: null, + }); + + // Verify the API key works initially + const checkResponseBeforeRevoke = await ProjectApiKey.User.check(createUserApiKeyResponse.body.value); + expect(checkResponseBeforeRevoke).toMatchInlineSnapshot(` + { + "created_at_millis": , + "description": "Test API Key to Revoke", + "id": "", + "is_public": false, + "type": "user", + "user_id": "", + "value": { "last_four": }, + } + `); + + // Revoke the API key through credential scanning + const revokeResponse = await niceBackendFetch("/api/v1/integrations/credential-scanning/revoke", { + method: "POST", + accessType: "server", + body: { + api_key: createUserApiKeyResponse.body.value, + }, + }); + + expect(revokeResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": true }, + "headers": Headers {