mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Implement endpint and add testst
This commit is contained in:
parent
cc3c563c76
commit
7c53fbe328
@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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.",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user