Implement endpint and add testst

This commit is contained in:
moritz 2025-04-08 16:31:16 -07:00
parent cc3c563c76
commit 7c53fbe328
5 changed files with 623 additions and 12 deletions

View File

@ -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<string>();
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 = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">API Key Revoked</h2>
<p style="color: #555; font-size: 16px; line-height: 1.5;">
Your API key "${updatedApiKey.description}" has been automatically revoked because it was found in a public repository.
</p>
<p style="color: #555; font-size: 16px; line-height: 1.5;">
This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support.
</p>
<p style="color: #555; font-size: 16px; line-height: 1.5;">
Please create a new API key if needed.
</p>
</div>
`;
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",
};
},
});

View File

@ -300,16 +300,7 @@ async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
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<EmailConfig> {
};
}
}
export async function getSharedEmailConfig(displayName: string): Promise<EmailConfig> {
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',
};
}

View File

@ -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({

View File

@ -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": <stripped field 'created_at_millis'>,
"description": "Test API Key to Revoke",
"id": "<stripped UUID>",
"is_public": false,
"type": "user",
"user_id": "<stripped UUID>",
"value": { "last_four": <stripped field '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 { <some fields may have been hidden> },
}
`);
// Verify the API key is no longer valid
const checkResponseAfterRevoke = await ProjectApiKey.User.check(createUserApiKeyResponse.body.value);
expect(checkResponseAfterRevoke).toMatchInlineSnapshot(`
{
"code": "API_KEY_REVOKED",
"error": "API key has been revoked.",
}
`);
// Verify that an email notification was sent
const messages = (await user1.mailbox.fetchMessages({ noBody: true })).filter((m: MailboxMessage) => m.subject.includes("API Key Revoked"));
expect(messages).toMatchInlineSnapshot(`
[
MailboxMessage {
"from": "Stack Auth <noreply@example.com>",
"subject": "API Key Revoked: Test API Key to Revoke",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
// Verify the email content
const emailContent = await user1.mailbox.fetchMessages();
const revocationEmail = emailContent.find((m: MailboxMessage) => m.subject === "API Key Revoked: Test API Key to Revoke");
expect(revocationEmail).toBeDefined();
expect(revocationEmail?.body?.text).toMatchInlineSnapshot(`
deindent\`
---------------
API Key Revoked
---------------
Your API key "Test API Key to Revoke" 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.
\`
`);
// Verify second user did not receive the email
const messages2 = (await user2.mailbox.fetchMessages({ noBody: true })).filter((m: MailboxMessage) => m.subject.includes("API Key Revoked"));
expect(messages2).toMatchInlineSnapshot(`[]`);
});
it("should send email notification to team members when revoking a team 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, user3] = await User.createMultiple(3);
// Create a team and add both users
const { teamId } = await Team.create();
await Team.addMember(teamId, user1.userId);
await Team.addMember(teamId, user2.userId);
await Team.addMember(teamId, user3.userId);
await Team.addPermission(teamId, user1.userId, "$manage_api_keys");
await Team.addPermission(teamId, user2.userId, "$manage_api_keys");
// we do not give user3 the permission to manage api keys
User.setBackendContextFromUser(user1);
const { createTeamApiKeyResponse } = await ProjectApiKey.Team.create({
team_id: teamId,
description: "Test Team API Key to Revoke",
expires_at_millis: null,
});
// Verify the API key works initially
const checkResponseBeforeRevoke = await ProjectApiKey.Team.check(createTeamApiKeyResponse.body.value);
expect(checkResponseBeforeRevoke).toMatchInlineSnapshot(`
{
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Test Team API Key to Revoke",
"id": "<stripped UUID>",
"is_public": false,
"team_id": "<stripped UUID>",
"type": "team",
"value": { "last_four": <stripped field '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: createTeamApiKeyResponse.body.value,
},
});
expect(revokeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the API key is no longer valid
const checkResponseAfterRevoke = await ProjectApiKey.Team.check(createTeamApiKeyResponse.body.value);
expect(checkResponseAfterRevoke).toMatchInlineSnapshot(`
{
"code": "API_KEY_REVOKED",
"error": "API key has been revoked.",
}
`);
// Verify that email notifications were sent to both team members
const user1_revocation_email = (await user1.mailbox.fetchMessages()).filter((m: MailboxMessage) => m.subject === "API Key Revoked: Test Team API Key to Revoke");
const user2_revocation_email = (await user2.mailbox.fetchMessages()).filter((m: MailboxMessage) => m.subject === "API Key Revoked: Test Team API Key to Revoke");
const user3_revocation_email = (await user3.mailbox.fetchMessages()).filter((m: MailboxMessage) => m.subject === "API Key Revoked: Test Team API Key to Revoke");
expect(user1_revocation_email).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "\\n <div style=\\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\\">\\n <h2 style=\\"color: #333;\\">API Key Revoked</h2>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n Your API key \\"Test Team API Key to Revoke\\" has been automatically revoked because it was found in a public repository.\\n </p>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support.\\n </p>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n Please create a new API key if needed.\\n </p>\\n </div>\\n \\n",
"text": deindent\`
---------------
API Key Revoked
---------------
Your API key "Test Team API Key to Revoke" 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.
\`,
},
"from": "Stack Auth <noreply@example.com>",
"subject": "API Key Revoked: Test Team API Key to Revoke",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
expect(user2_revocation_email).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "\\n <div style=\\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\\">\\n <h2 style=\\"color: #333;\\">API Key Revoked</h2>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n Your API key \\"Test Team API Key to Revoke\\" has been automatically revoked because it was found in a public repository.\\n </p>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n This is an automated security measure to protect your api keys from being leaked. If you believe this was a mistake, please contact support.\\n </p>\\n <p style=\\"color: #555; font-size: 16px; line-height: 1.5;\\">\\n Please create a new API key if needed.\\n </p>\\n </div>\\n \\n",
"text": deindent\`
---------------
API Key Revoked
---------------
Your API key "Test Team API Key to Revoke" 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.
\`,
},
"from": "Stack Auth <noreply@example.com>",
"subject": "API Key Revoked: Test Team API Key to Revoke",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
expect(user3_revocation_email).toMatchInlineSnapshot(`[]`);
}, { timeout: 120_000 });
it("should handle already revoked API keys gracefully", async ({ expect }: { expect: any }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true, allow_team_api_keys: true, allow_user_api_keys: true } });
const user1 = await User.create();
User.setBackendContextFromUser(user1);
// Create a user API key
const { createUserApiKeyResponse } = await ProjectApiKey.User.create({
user_id: user1.userId,
description: "Test API Key Already Revoked",
expires_at_millis: null,
});
// Manually revoke the API key first
const userRevokeResponse = await ProjectApiKey.User.revoke(createUserApiKeyResponse.body.id);
expect(userRevokeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Test API Key Already Revoked",
"id": "<stripped UUID>",
"is_public": false,
"manually_revoked_at_millis": <stripped field 'manually_revoked_at_millis'>,
"type": "user",
"user_id": "<stripped UUID>",
"value": { "last_four": <stripped field 'last_four'> },
},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the API key is already revoked
const checkResponseBeforeRevoke = await ProjectApiKey.User.check(createUserApiKeyResponse.body.value);
expect(checkResponseBeforeRevoke).toMatchInlineSnapshot(`
{
"code": "API_KEY_REVOKED",
"error": "API key has been revoked.",
}
`);
// Try to 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,
},
});
// Should still return success but not send another email
expect(revokeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify no additional email was sent
const messages = await user1.mailbox.fetchMessages({ noBody: true });
const revocationEmails = messages.filter((m: MailboxMessage) =>
m.subject === "API Key Revoked: Test API Key Already Revoked"
);
expect(revocationEmails.length).toBe(0);
});
it("should error when api key is not found", async ({ expect }: { expect: any }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
const fakeApiKey = `stack_test_nonexistent_${generateSecureRandomString()}`;
// Try to revoke the non-existent API key
const revokeResponse = await niceBackendFetch("/api/v1/integrations/credential-scanning/revoke", {
method: "POST",
accessType: "server",
body: {
api_key: fakeApiKey,
},
});
// Expect an error response (e.g., 404 Not Found)
expect(revokeResponse.status).toBe(404);
expect(revokeResponse.body).toMatchInlineSnapshot(`
{
"code": "API_KEY_NOT_FOUND",
"error": "API key not found.",
}
`);
});

View File

@ -1258,6 +1258,15 @@ const ApiKeyNotFound = createKnownErrorConstructor(
() => [] as const,
);
const PublicApiKeyCannotBeRevoked = createKnownErrorConstructor(
ApiKeyNotValid,
"PUBLIC_API_KEY_CANNOT_BE_REVOKED",
() => [
400,
"Public API keys cannot be revoked by the secretscanner endpoint.",
] as const,
() => [] as const,
);
const PermissionIdAlreadyExists = createKnownErrorConstructor(
KnownError,
@ -1321,6 +1330,7 @@ export const KnownErrors = {
UserIdDoesNotExist,
UserNotFound,
ApiKeyNotFound,
PublicApiKeyCannotBeRevoked,
ProjectNotFound,
SignUpNotEnabled,
PasswordAuthenticationNotEnabled,