general provision

This commit is contained in:
Zai Shi 2025-05-19 14:03:21 -07:00
parent c7137b6837
commit 29d4175757
18 changed files with 1112 additions and 1 deletions

View File

@ -0,0 +1,3 @@
import { domainCrudHandlers } from "../crud";
export const DELETE = domainCrudHandlers.deleteHandler;

View File

@ -0,0 +1,82 @@
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { CrudTypeOf, createCrud } from "@stackframe/stack-shared/dist/crud";
import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields";
import { yupMixed, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { projectsCrudHandlers } from "../../internal/projects/current/crud";
const domainSchema = schemaFields.urlSchema.defined()
.matches(/^https?:\/\//, 'URL must start with http:// or https://')
.meta({ openapiField: { description: 'URL. Must start with http:// or https://', exampleValue: 'https://example.com' } });
const domainReadSchema = yupObject({
domain: domainSchema,
});
const domainCreateSchema = yupObject({
domain: domainSchema,
});
export const domainDeleteSchema = yupMixed();
export const domainCrud = createCrud({
adminReadSchema: domainReadSchema,
adminCreateSchema: domainCreateSchema,
adminDeleteSchema: domainDeleteSchema,
docs: {
adminList: {
hidden: true,
},
adminRead: {
hidden: true,
},
adminCreate: {
hidden: true,
},
adminUpdate: {
hidden: true,
},
adminDelete: {
hidden: true,
},
},
});
export type DomainCrud = CrudTypeOf<typeof domainCrud>;
export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domainCrud, {
paramsSchema: yupObject({
domain: domainSchema.optional(),
}),
onCreate: async ({ auth, data, params }) => {
const oldDomains = auth.tenancy.config.domains;
await projectsCrudHandlers.adminUpdate({
data: {
config: {
domains: [...oldDomains, { domain: data.domain, handler_path: "/handler" }],
},
},
tenancy: auth.tenancy,
allowedErrorTypes: [StatusError],
});
return { domain: data.domain };
},
onDelete: async ({ auth, params }) => {
const oldDomains = auth.tenancy.config.domains;
await projectsCrudHandlers.adminUpdate({
data: {
config: { domains: oldDomains.filter((domain) => domain.domain !== params.domain) },
},
tenancy: auth.tenancy,
allowedErrorTypes: [StatusError],
});
},
onList: async ({ auth }) => {
return {
items: auth.tenancy.config.domains.map((domain) => ({ domain: domain.domain })),
is_paginated: false,
};
},
}));

View File

@ -0,0 +1,4 @@
import { domainCrudHandlers } from "./crud";
export const GET = domainCrudHandlers.listHandler;
export const POST = domainCrudHandlers.createHandler;

View File

@ -0,0 +1,65 @@
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
url: yupString().defined(),
auth: yupObject({
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
type: serverOrHigherAuthTypeSchema.defined(),
}).defined(),
body: yupObject({
interaction_uid: yupString().defined(),
project_id: yupString().defined(),
neon_project_name: yupString().optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
authorization_code: yupString().defined(),
}).defined(),
}),
handler: async (req) => {
// Create an admin API key for the tenancy
const set = await prismaClient.apiKeySet.create({
data: {
projectId: req.body.project_id,
description: `Auto-generated for Neon${req.body.neon_project_name ? ` (${req.body.neon_project_name})` : ""}`,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100),
superSecretAdminKey: `sak_${generateSecureRandomString()}`,
},
});
// Create authorization code
const authorizationCode = generateSecureRandomString();
await prismaClient.projectWrapperCodes.create({
data: {
idpId: "stack-preconfigured-idp:integrations/neon",
interactionUid: req.body.interaction_uid,
authorizationCode,
cdfcResult: {
access_token: set.superSecretAdminKey,
token_type: "api_key",
project_id: req.body.project_id,
},
},
});
return {
statusCode: 200,
bodyType: "json",
body: {
authorization_code: authorizationCode,
},
};
},
});

View File

@ -3,7 +3,7 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { createNodeHttpServerDuplex } from "@stackframe/stack-shared/dist/utils/node-http";
import { NextRequest, NextResponse } from "next/server";
import { createOidcProvider } from "./idp";
import { createOidcProvider } from "../../../../oauth/idp/[[...route]]/idp";
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,31 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupNever, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { redirect } from "next/navigation";
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
url: yupString().defined(),
query: yupObject({
client_id: yupString().defined(),
redirect_uri: yupString().defined(),
state: yupString().defined(),
code_challenge: yupString().defined(),
code_challenge_method: yupString().oneOf(["S256"]).defined(),
response_type: yupString().oneOf(["code"]).defined(),
}).defined(),
}),
response: yupNever(),
handler: async (req) => {
const url = new URL(req.url);
if (url.pathname !== "/api/v1/integrations/neon/oauth/authorize") {
throw new StackAssertionError(`Expected pathname to be authorize endpoint but got ${JSON.stringify(url.pathname)}`, { url });
}
url.pathname = "/api/v1/integrations/neon/oauth/idp/auth";
url.search = new URLSearchParams({ ...req.query, scope: "openid" }).toString();
redirect(url.toString());
},
});

View File

@ -0,0 +1,79 @@
import { handleApiRequest } from "@/route-handlers/smart-route-handler";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { createNodeHttpServerDuplex } from "@stackframe/stack-shared/dist/utils/node-http";
import { NextRequest, NextResponse } from "next/server";
import { createOidcProvider } from "./idp";
export const dynamic = "force-dynamic";
const pathPrefix = "/api/v1/integrations/neon/oauth/idp";
// we want to initialize the OIDC provider lazily so it's not initiated at build time
let _oidcCallbackPromiseCache: Promise<any> | undefined;
function getOidcCallbackPromise() {
if (!_oidcCallbackPromiseCache) {
const apiBaseUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
const idpBaseUrl = new URL(pathPrefix, apiBaseUrl);
_oidcCallbackPromiseCache = (async () => {
const oidc = await createOidcProvider({
id: "stack-preconfigured-idp:integrations/neon",
baseUrl: idpBaseUrl.toString(),
});
return oidc.callback();
})();
}
return _oidcCallbackPromiseCache;
}
const handler = handleApiRequest(async (req: NextRequest) => {
const newUrl = req.url.replace(pathPrefix, "");
if (newUrl === req.url) {
throw new StackAssertionError("No path prefix found in request URL. Is the pathPrefix correct?", { newUrl, url: req.url, pathPrefix });
}
const newHeaders = new Headers(req.headers);
const incomingBody = new Uint8Array(await req.arrayBuffer());
const [incomingMessage, serverResponse] = await createNodeHttpServerDuplex({
method: req.method,
originalUrl: new URL(req.url),
url: new URL(newUrl),
headers: newHeaders,
body: incomingBody,
});
await (await getOidcCallbackPromise())(incomingMessage, serverResponse);
const body = new Uint8Array(serverResponse.bodyChunks.flatMap(chunk => [...chunk]));
let headers: [string, string][] = [];
for (const [k, v] of Object.entries(serverResponse.getHeaders())) {
if (Array.isArray(v)) {
for (const vv of v) {
headers.push([k, vv]);
}
} else {
headers.push([k, `${v}`]);
}
}
// filter out session cookies; we don't want to keep sessions open, every OAuth flow should start a new session
headers = headers.filter(([k, v]) => k !== "set-cookie" || !v.toString().match(/^_session\.?/));
return new NextResponse(body, {
headers: headers,
status: {
// our API never returns 301 or 302 by convention, so transform them to 307 or 308
301: 308,
302: 307,
}[serverResponse.statusCode] ?? serverResponse.statusCode,
statusText: serverResponse.statusMessage,
});
});
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const OPTIONS = handler;
export const HEAD = handler;

View File

@ -0,0 +1,28 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
url: yupString().defined(),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["text"]).defined(),
body: yupString().defined(),
}),
handler: async (req) => {
return {
statusCode: 200,
bodyType: "text",
body: deindent`
Authorization endpoint: ${new URL("authorize", req.url + "/").toString()}
Token endpoint: ${new URL("token", req.url + "/").toString()}
`,
};
},
});

View File

@ -0,0 +1,86 @@
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { neonAuthorizationHeaderSchema, yupMixed, yupNumber, yupObject, yupString, yupTuple, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
url: yupString().defined(),
body: yupObject({
grant_type: yupString().oneOf(["authorization_code"]).defined(),
code: yupString().defined(),
code_verifier: yupString().defined(),
redirect_uri: yupString().defined(),
}).defined(),
headers: yupObject({
authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(),
}).defined(),
}),
response: yupUnion(
yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
access_token: yupString().defined(),
token_type: yupString().oneOf(["api_key"]).defined(),
project_id: yupString().defined(),
}).defined(),
}),
yupObject({
statusCode: yupNumber().defined(),
bodyType: yupString().oneOf(["text"]).defined(),
body: yupMixed<any>().defined(),
}),
),
handler: async (req) => {
const tokenResponse = await fetch(new URL("/api/v1/integrations/neon/oauth/idp/token", getEnvVariable("NEXT_PUBLIC_STACK_API_URL")), {
method: "POST",
body: new URLSearchParams(req.body).toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: req.headers.authorization[0],
},
});
if (!tokenResponse.ok) {
return {
statusCode: tokenResponse.status,
bodyType: "text",
body: await tokenResponse.text(),
};
}
const tokenResponseBody = await tokenResponse.json();
const userInfoResponse = await fetch(new URL("/api/v1/integrations/neon/oauth/idp/me", getEnvVariable("NEXT_PUBLIC_STACK_API_URL")), {
method: "GET",
headers: {
Authorization: `Bearer ${tokenResponseBody.access_token}`,
},
});
if (!userInfoResponse.ok) {
const text = await userInfoResponse.text();
throw new StackAssertionError("Failed to fetch user info? This should never happen", { text, userInfoResponse });
}
const userInfoResponseBody = await userInfoResponse.json();
const accountId = userInfoResponseBody.sub;
const mapping = await prismaClient.idPAccountToCdfcResultMapping.findUnique({
where: {
idpId: "stack-preconfigured-idp:integrations/neon",
idpAccountId: accountId,
},
});
if (!mapping) {
throw new StackAssertionError("No mapping found for account", { accountId });
}
return {
statusCode: 200,
bodyType: "json",
body: mapping.cdfcResult as any,
};
},
});

View File

@ -0,0 +1,79 @@
import { createApiKeySet } from "@/lib/internal-api-keys";
import { createOrUpdateProject } from "@/lib/projects";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
body: yupObject({
display_name: projectDisplayNameSchema.defined(),
}).defined(),
headers: yupObject({
authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
project_id: yupString().defined(),
super_secret_admin_key: yupString().defined(),
}).defined(),
}),
handler: async (req) => {
const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!;
const createdProject = await createOrUpdateProject({
ownerIds: [],
type: 'create',
data: {
display_name: req.body.display_name,
description: "Created with Neon",
config: {
oauth_providers: [
{
id: "google",
type: "shared",
},
{
id: "github",
type: "shared",
},
],
allow_localhost: true,
credential_enabled: true
},
}
});
await prismaClient.neonProvisionedProject.create({
data: {
projectId: createdProject.id,
neonClientId: clientId,
},
});
const set = await createApiKeySet({
projectId: createdProject.id,
description: `Auto-generated for Neon (${req.body.display_name})`,
expires_at_millis: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).getTime(),
has_publishable_client_key: false,
has_secret_server_key: false,
has_super_secret_admin_key: true,
});
return {
statusCode: 200,
bodyType: "json",
body: {
project_id: createdProject.id,
super_secret_admin_key: set.super_secret_admin_key!,
},
};
},
});

View File

@ -0,0 +1,3 @@
import { neonIntegrationProjectTransferCodeHandler } from "../verification-code-handler";
export const POST = neonIntegrationProjectTransferCodeHandler.checkHandler;

View File

@ -0,0 +1,3 @@
import { neonIntegrationProjectTransferCodeHandler } from "./verification-code-handler";
export const POST = neonIntegrationProjectTransferCodeHandler.postHandler;

View File

@ -0,0 +1,94 @@
import { prismaClient } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
export const neonIntegrationProjectTransferCodeHandler = createVerificationCodeHandler({
metadata: {
post: {
hidden: true,
},
check: {
hidden: true,
},
},
type: VerificationCodeType.NEON_INTEGRATION_PROJECT_TRANSFER,
data: yupObject({
neon_client_id: yupString().defined(),
project_id: yupString().defined(),
}).defined(),
method: yupObject({}),
requestBody: yupObject({}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
project_id: yupString().defined(),
}).defined(),
}),
async validate(tenancy, method, data) {
const project = tenancy.project;
if (project.id !== "internal") throw new StatusError(400, "This endpoint is only available for internal projects.");
const neonProvisionedProjects = await prismaClient.neonProvisionedProject.findMany({
where: {
projectId: data.project_id,
neonClientId: data.neon_client_id,
},
});
if (neonProvisionedProjects.length === 0) throw new StatusError(400, "The project to transfer was not provisioned by Neon or has already been transferred.");
},
async handler(tenancy, method, data, body, user) {
const project = tenancy.project;
if (!user) throw new KnownErrors.UserAuthenticationRequired;
await prismaClient.$transaction(async (tx) => {
const neonProvisionedProject = await tx.neonProvisionedProject.deleteMany({
where: {
projectId: data.project_id,
neonClientId: data.neon_client_id,
},
});
if (neonProvisionedProject.count === 0) throw new StatusError(400, "The project to transfer was not provisioned by Neon or has already been transferred.");
const recentDbUser = await tx.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
projectUserId: user.id,
},
},
}) ?? throwErr("Authenticated user not found in transaction. Something went wrong. Did the user delete their account at the wrong time? (Very unlikely.)");
const rduServerMetadata: any = recentDbUser.serverMetadata;
await tx.projectUser.update({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
projectUserId: user.id,
},
},
data: {
serverMetadata: {
...typeof rduServerMetadata === "object" ? rduServerMetadata : {},
managedProjectIds: [
...(Array.isArray(rduServerMetadata?.managedProjectIds) ? rduServerMetadata.managedProjectIds : []),
data.project_id,
],
},
},
});
});
return {
statusCode: 200,
bodyType: "json",
body: {
project_id: data.project_id,
},
};
}
});

View File

@ -0,0 +1,3 @@
import { POST as initiateTransfer } from "../route";
export const POST = initiateTransfer;

View File

@ -0,0 +1,106 @@
import { getProject } from "@/lib/projects";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { neonAuthorizationHeaderSchema, urlSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http";
import { neonIntegrationProjectTransferCodeHandler } from "./confirm/verification-code-handler";
async function validateAndGetTransferInfo(authorizationHeader: string, projectId: string) {
const [clientId, clientSecret] = decodeBasicAuthorizationHeader(authorizationHeader)!;
const internalProject = await getProject("internal") ?? throwErr("Internal project not found");
const neonProvisionedProject = await prismaClient.neonProvisionedProject.findUnique({
where: {
projectId,
neonClientId: clientId,
},
});
if (!neonProvisionedProject) {
// note: Neon relies on this exact status code and error message, so don't change it without consulting them first
throw new StatusError(400, "This project either doesn't exist or the current Neon client is not authorized to transfer it. Note that projects can only be transferred once.");
}
return {
neonProvisionedProject,
internalProject,
};
}
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
query: yupObject({
project_id: yupString().defined(),
}).defined(),
headers: yupObject({
authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
message: yupString().defined(),
}).defined(),
}),
handler: async (req) => {
await validateAndGetTransferInfo(req.headers.authorization[0], req.query.project_id);
return {
statusCode: 200,
bodyType: "json",
body: {
message: "Ready to transfer project; please use the POST method to initiate it.",
},
};
},
});
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
body: yupObject({
project_id: yupString().defined(),
}).defined(),
headers: yupObject({
authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
confirmation_url: urlSchema.defined(),
}).defined(),
}),
handler: async (req) => {
const { neonProvisionedProject } = await validateAndGetTransferInfo(req.headers.authorization[0], req.body.project_id);
const transferCodeObj = await neonIntegrationProjectTransferCodeHandler.createCode({
tenancy: await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID),
method: {},
data: {
project_id: neonProvisionedProject.projectId,
neon_client_id: neonProvisionedProject.neonClientId,
},
callbackUrl: new URL("/integrations/neon/projects/transfer/confirm", getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")),
expiresInMs: 1000 * 60 * 60,
});
return {
statusCode: 200,
bodyType: "json",
body: {
confirmation_url: transferCodeObj.link.toString(),
},
};
},
});

View File

@ -0,0 +1,161 @@
import { it } from "../../../../../helpers";
import { Auth, Project, niceBackendFetch } from "../../../../backend-helpers";
it("list domains", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const response = await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("creates domains for internal project", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const response = await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "POST",
body: {
domain: "https://test-domain.example.com",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": { "domain": "https://test-domain.example.com" },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("adds two different domains", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndGetAdminToken();
// Add first domain
await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "POST",
body: {
domain: "https://first-domain.example.com",
},
});
// Add second domain
await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "POST",
body: {
domain: "https://second-domain.example.com",
},
});
// List domains to verify both were added
const listResponse = await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{ "domain": "https://first-domain.example.com" },
{ "domain": "https://second-domain.example.com" },
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("adds two domains and deletes one", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndGetAdminToken();
// Add first domain
await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "POST",
body: {
domain: "https://domain-to-keep.example.com",
},
});
// Add second domain
await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "POST",
body: {
domain: "https://domain-to-delete.example.com",
},
});
// Delete the second domain
const deleteResponse = await niceBackendFetch(`/api/v1/integrations/domains/${encodeURIComponent("https://domain-to-delete.example.com")}`, {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
method: "DELETE",
});
expect(deleteResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// List domains to verify only one remains
const listResponse = await niceBackendFetch("/api/v1/integrations/domains", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [{ "domain": "https://domain-to-keep.example.com" }],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -0,0 +1,284 @@
import { encodeBase64Url } from "@stackframe/stack-shared/dist/utils/bytes";
import { expect } from "vitest";
import { it, updateCookiesFromResponse } from "../../../../../helpers";
import { Auth, InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../backend-helpers";
async function authorizePart1(redirectUri: string = "http://localhost:30000/api/v2/auth/authorize") {
let cookies = "";
const first = await niceBackendFetch("/api/v1/integrations/oauth/authorize", {
method: "GET",
query: {
response_type: "code",
client_id: "example-local",
redirect_uri: redirectUri,
state: encodeBase64Url(new TextEncoder().encode(JSON.stringify({ details: { example_project_name: 'example-project' } }))),
code_challenge: "xf6HY7PIgoaCf_eMniSt-45brYE2J_05C9BnfIbueik",
code_challenge_method: "S256",
},
redirect: "manual",
});
cookies = updateCookiesFromResponse(cookies, first);
let second = undefined;
let third = undefined;
if (first.status === 307) {
second = await first.follow({ redirect: "manual", headers: { "Cookie": cookies } });
cookies = updateCookiesFromResponse(cookies, second);
if (second.status === 303) {
third = await second.follow({ redirect: "manual", headers: { "Cookie": cookies } });
cookies = updateCookiesFromResponse(cookies, third);
}
}
return { responses: [first, second, third], cookies };
}
async function authorizePart2(interactionUid: string, authorizationCode: string, cookies: string) {
const first = await niceBackendFetch(`/api/v1/integrations/oauth/idp/interaction/${encodeURIComponent(interactionUid)}/done`, {
query: {
code: authorizationCode,
},
headers: {
"Cookie": cookies,
},
redirect: "manual",
});
cookies = updateCookiesFromResponse(cookies, first);
let second = undefined;
let third = undefined;
if (first.status === 200) {
second = await niceBackendFetch(`/api/v1/integrations/oauth/idp/interaction/${encodeURIComponent(interactionUid)}/done`, {
method: "POST",
query: {
code: authorizationCode,
},
headers: {
"Cookie": cookies,
},
redirect: "manual",
});
cookies = updateCookiesFromResponse(cookies, second);
if (second.status === 303) {
third = await second.follow({
headers: { "Cookie": cookies },
redirect: "manual",
});
cookies = updateCookiesFromResponse(cookies, third);
}
}
return { responses: [first, second, third], cookies };
}
async function authorize(projectId: string) {
const authorizePart1Response = await authorizePart1();
expect(authorizePart1Response.responses).toMatchInlineSnapshot(`
[
NiceResponse {
"status": 307,
"headers": Headers {
"location": "http://localhost:8102/api/v1/integrations/oauth/idp/auth?response_type=code&client_id=example-local&redirect_uri=%3Cstripped+query+param%3E&state=%3Cstripped+query+param%3E&code_challenge=%3Cstripped+query+param%3E&code_challenge_method=S256&scope=openid",
<some fields may have been hidden>,
},
},
NiceResponse {
"status": 303,
"body": "Redirecting to <a href=\\"http://localhost:8102/api/v1/integrations/oauth/idp/interaction/<stripped interaction UID>\\">http://localhost:8102/api/v1/integrations/oauth/idp/interaction/<stripped interaction UID></a>.",
"headers": Headers {
"location": "http://localhost:8102/api/v1/integrations/oauth/idp/interaction/<stripped interaction UID>",
"set-cookie": <setting cookie "_interaction" at path "/api/v1/integrations/oauth/idp/interaction/<stripped interaction UID>" to <stripped cookie value>>,
"set-cookie": <setting cookie "_interaction.sig" at path "/api/v1/integrations/oauth/idp/interaction/<stripped interaction UID>" to <stripped cookie value>>,
"set-cookie": <setting cookie "_interaction_resume" at path "/api/v1/integrations/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
"set-cookie": <setting cookie "_interaction_resume.sig" at path "/api/v1/integrations/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
<some fields may have been hidden>,
},
},
NiceResponse {
"status": 307,
"body": "http://localhost:8101/integrations//confirm?interaction_uid=%3Cstripped+query+param%3E&amp=",
"headers": Headers {
"location": "http://localhost:8101/integrations//confirm?interaction_uid=%3Cstripped+query+param%3E&example_project_name=example-project",
<some fields may have been hidden>,
},
},
]
`);
const dashboardConfirmUrl = new URL(authorizePart1Response.responses[2]!.headers.get("location")!);
const interactionUid = dashboardConfirmUrl.searchParams.get("interaction_uid")!;
const confirmResponse = await niceBackendFetch(`/api/v1/integrations//internal/confirm`, {
method: "POST",
accessType: "server",
body: {
project_id: projectId,
interaction_uid: interactionUid,
},
});
expect(confirmResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "authorization_code": <stripped field 'authorization_code'> },
"headers": Headers { <some fields may have been hidden> },
}
`);
const innerAuthorizationCode = confirmResponse.body.authorization_code;
const authorizePart2Response = await authorizePart2(interactionUid, innerAuthorizationCode, authorizePart1Response.cookies);
expect(authorizePart2Response.responses).toMatchInlineSnapshot(`
[
NiceResponse {
"status": 200,
"body": "\\n <html>\\n <head>\\n <title>Redirecting... — Stack Auth</title>\\n <style id=\\"gradient-style\\">\\n body {\\n color: white;\\n background-image: linear-gradient(45deg, #000, #444, #000, #444, #000, #444, #000);\\n background-size: 400% 400%;\\n background-repeat: no-repeat;\\n animation: celebrate-gradient 60s linear infinite;\\n min-height: 100vh;\\n }\\n @keyframes celebrate-gradient {\\n 0% { background-position: 0% 100%; }\\n 100% { background-position: 100% 0%; }\\n }\\n </style>\\n </head>\\n <body>\\n <form id=\\"continue-form\\" method=\\"POST\\">\\n If you are not redirected, please press the button below.<br>\\n <input type=\\"submit\\" value=\\"Continue\\">\\n </form>\\n <script>\\n document.getElementById('continue-form').style.visibility = 'hidden';\\n document.getElementById('continue-form').submit();\\n setTimeout(() => {\\n document.getElementById('gradient-style').remove();\\n document.getElementById('continue-form').style.visibility = 'visible';\\n }, 12000);\\n </script>\\n </body>\\n </html>\\n ",
"headers": Headers { <some fields may have been hidden> },
},
NiceResponse {
"status": 303,
"headers": Headers {
"location": "http://localhost:8102/api/v1/integrations/oauth/idp/auth/<stripped auth UID>",
<some fields may have been hidden>,
},
},
NiceResponse {
"status": 303,
"body": "http://localhost:30000/api/v2/auth/authorize?code=%3Cstripped+query+param%3E&amp=",
"headers": Headers {
"location": "http://localhost:30000/api/v2/auth/authorize?code=%3Cstripped+query+param%3E&state=%3Cstripped+query+param%3E&iss=http%3A%2F%2Flocalhost%3A8102%2Fapi%2Fv1%2Fintegrations%2Fexample%2Foauth%2Fidp",
"set-cookie": <setting cookie "_interaction_resume" at path "/api/v1/integrations/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
"set-cookie": <setting cookie "_interaction_resume.sig" at path "/api/v1/integrations/oauth/idp/auth/<stripped auth UID>" to <stripped cookie value>>,
<some fields may have been hidden>,
},
},
]
`);
const authorizationCode = new URL(authorizePart2Response.responses[2]!.headers.get("location")!).searchParams.get("code")!;
return { authorizationCode };
}
it(`should redirect to the correct callback URL`, async ({}) => {
await Auth.Otp.signIn();
const createdProject = await Project.create();
await authorize(createdProject.projectId);
});
it(`should not redirect to the incorrect callback URL`, async ({}) => {
await Auth.Otp.signIn();
await Project.create();
const result = await authorizePart1("http://localhost:30000/api/v2/wrong-url/authorize");
expect(result).toMatchInlineSnapshot(`
{
"cookies": "",
"responses": [
NiceResponse {
"status": 307,
"headers": Headers {
"location": "http://localhost:8102/api/v1/integrations/oauth/idp/auth?response_type=code&client_id=example-local&redirect_uri=%3Cstripped+query+param%3E&state=%3Cstripped+query+param%3E&code_challenge=%3Cstripped+query+param%3E&code_challenge_method=S256&scope=openid",
<some fields may have been hidden>,
},
},
NiceResponse {
"status": 400,
"body": {
"error": "invalid_redirect_uri",
"error_description": "redirect_uri did not match any of the client's registered redirect_uris",
"iss": "http://localhost:8102/api/v1/integrations/oauth/idp",
"state": "eyJkZXRhaWxzIjp7Im5lb25fcHJvamVjdF9uYW1lIjoibmVvbi1wcm9qZWN0In19",
},
"headers": Headers { <some fields may have been hidden> },
},
undefined,
],
}
`);
});
it(`should exchange the authorization code for an admin API key that works`, async ({}) => {
await Auth.Otp.signIn();
const createdProject = await Project.create();
const { authorizationCode } = await authorize(createdProject.projectId);
const tokenResponse = await niceBackendFetch(`/api/v1/integrations/oauth/token`, {
method: "POST",
body: {
grant_type: "authorization_code",
code: authorizationCode,
code_verifier: "W2LPAD4M4ES-3wBjzU6J5ApykmuxQy5VTs3oSmtboDM",
redirect_uri: "http://localhost:30000/api/v2/auth/authorize",
},
headers: {
"Authorization": "Basic bmVvbi1sb2NhbDpuZW9uLWxvY2FsLXNlY3JldA=="
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"project_id": "<stripped UUID>",
"token_type": "api_key",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(tokenResponse.body.project_id).toBe(createdProject.projectId);
const apiKey = tokenResponse.body.access_token;
backendContext.set({
projectKeys: {
projectId: createdProject.projectId,
superSecretAdminKey: apiKey,
},
userAuth: null,
});
const listApiKeysResponse = await InternalApiKey.list();
expect(listApiKeysResponse).toMatchInlineSnapshot(`
{
"is_paginated": false,
"items": [
{
"created_at_millis": <stripped field 'created_at_millis'>,
"description": "Auto-generated for Example",
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"super_secret_admin_key": { "last_four": <stripped field 'last_four'> },
},
],
}
`);
});
it(`should not exchange the authorization code when the client secret is incorrect`, async ({}) => {
await Auth.Otp.signIn();
const createdProject = await Project.create();
const { authorizationCode } = await authorize(createdProject.projectId);
const tokenResponse = await niceBackendFetch(`/api/v1/integrations/oauth/token`, {
method: "POST",
body: {
grant_type: "authorization_code",
code: authorizationCode,
code_verifier: "W2LPAD4M4ES-3wBjzU6J5ApykmuxQy5VTs3oSmtboDM",
redirect_uri: "http://localhost:30000/api/v2/auth/authorize",
},
headers: {
"Authorization": "Basic bmVvbi1sb2NhbDpuZW9uLWxvY2FsLXNlY2JldA=="
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/integrations/oauth/token:
- Invalid client_id:client_secret values; did you use the correct values for the Example integration?
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/integrations/oauth/token:
- Invalid client_id:client_secret values; did you use the correct values for the Example integration?
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});