mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
general provision
This commit is contained in:
parent
c7137b6837
commit
29d4175757
@ -0,0 +1,3 @@
|
||||
import { domainCrudHandlers } from "../crud";
|
||||
|
||||
export const DELETE = domainCrudHandlers.deleteHandler;
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@ -0,0 +1,4 @@
|
||||
import { domainCrudHandlers } from "./crud";
|
||||
|
||||
export const GET = domainCrudHandlers.listHandler;
|
||||
export const POST = domainCrudHandlers.createHandler;
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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());
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
28
apps/backend/src/app/api/latest/integrations/oauth/route.tsx
Normal file
28
apps/backend/src/app/api/latest/integrations/oauth/route.tsx
Normal 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()}
|
||||
`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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!,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { neonIntegrationProjectTransferCodeHandler } from "../verification-code-handler";
|
||||
|
||||
export const POST = neonIntegrationProjectTransferCodeHandler.checkHandler;
|
||||
@ -0,0 +1,3 @@
|
||||
import { neonIntegrationProjectTransferCodeHandler } from "./verification-code-handler";
|
||||
|
||||
export const POST = neonIntegrationProjectTransferCodeHandler.postHandler;
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { POST as initiateTransfer } from "../route";
|
||||
|
||||
export const POST = initiateTransfer;
|
||||
@ -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(),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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&=",
|
||||
"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&=",
|
||||
"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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user