From 0af77c1d8450334974450941bd0e33ba2cff6a8e Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 17 Mar 2026 12:41:08 -0700 Subject: [PATCH 1/7] New { type: "hosted" } for page URLs Other minor redirect URL changes: - app.urls.* for auth is now aware of after_auth_return_to - redirectToSignOut now sets and preserves after_auth_return_to - OAuth sign-in after_auth_return_to now carries callback-return context --- apps/backend/.env.development | 1 + .../migration.sql | 2 + .../tests/preserve-null-and-allow-uuid.ts | 81 ++++ apps/backend/prisma/schema.prisma | 43 ++- .../oauth/authorize/[provider_id]/route.tsx | 10 +- .../oauth/cross-domain/authorize/route.tsx | 174 +++++++++ apps/backend/src/oauth/model.tsx | 18 +- apps/dashboard/.env.development | 1 + apps/e2e/.env.development | 1 + .../api/v1/auth/oauth/authorize.test.ts | 55 +++ .../auth/oauth/cross-domain-authorize.test.ts | 232 +++++++++++ apps/e2e/tests/js/cross-domain-auth.test.ts | 264 +++++++++++++ claude/CLAUDE-KNOWLEDGE.md | 36 ++ examples/demo/.env.development | 1 + .../src/app/cross-domain-handoff/page.tsx | 185 +++++++++ examples/demo/src/stack.tsx | 4 +- packages/template/.eslintrc.cjs | 16 + .../payments/payments-panel.tsx | 5 +- .../src/components-page/oauth-callback.tsx | 3 +- .../components-page/stack-handler-client.tsx | 49 ++- packages/template/src/lib/auth.ts | 2 + packages/template/src/lib/env.ts | 74 ++++ .../apps/implementations/client-app-impl.ts | 193 ++++++++-- .../stack-app/apps/implementations/common.ts | 55 +-- .../implementations/redirect-page-urls.ts | 362 ++++++++++++++++++ .../stack-app/apps/interfaces/client-app.ts | 6 +- packages/template/src/lib/stack-app/common.ts | 41 +- packages/template/src/lib/stack-app/index.ts | 3 + .../src/lib/stack-app/url-targets.test.ts | 73 ++++ .../template/src/lib/stack-app/url-targets.ts | 296 ++++++++++++++ 30 files changed, 2151 insertions(+), 135 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/migration.sql create mode 100644 apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/tests/preserve-null-and-allow-uuid.ts create mode 100644 apps/backend/src/app/api/latest/auth/oauth/cross-domain/authorize/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/cross-domain-authorize.test.ts create mode 100644 apps/e2e/tests/js/cross-domain-auth.test.ts create mode 100644 examples/demo/src/app/cross-domain-handoff/page.tsx create mode 100644 packages/template/src/lib/env.ts create mode 100644 packages/template/src/lib/stack-app/apps/implementations/redirect-page-urls.ts create mode 100644 packages/template/src/lib/stack-app/url-targets.test.ts create mode 100644 packages/template/src/lib/stack-app/url-targets.ts diff --git a/apps/backend/.env.development b/apps/backend/.env.development index c6f1072b9a..683c643578 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -1,5 +1,6 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02 NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01 +NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09 NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=false STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo diff --git a/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/migration.sql b/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/migration.sql new file mode 100644 index 0000000000..3ee579ba15 --- /dev/null +++ b/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ProjectUserAuthorizationCode" +ADD COLUMN "grantedRefreshTokenId" UUID; diff --git a/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/tests/preserve-null-and-allow-uuid.ts b/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/tests/preserve-null-and-allow-uuid.ts new file mode 100644 index 0000000000..87c16da2b6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260310150000_add_oauth_authorization_code_refresh_token_id/tests/preserve-null-and-allow-uuid.ts @@ -0,0 +1,81 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const projectUserId = randomUUID(); + const authorizationCode = `code-${randomUUID()}`; + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + await sql` + INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") + VALUES (${projectUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW()) + `; + await sql` + INSERT INTO "ProjectUserAuthorizationCode" ( + "tenancyId", + "projectUserId", + "authorizationCode", + "redirectUri", + "expiresAt", + "codeChallenge", + "codeChallengeMethod", + "newUser", + "afterCallbackRedirectUrl", + "createdAt", + "updatedAt" + ) + VALUES ( + ${tenancyId}::uuid, + ${projectUserId}::uuid, + ${authorizationCode}, + 'https://example.com/callback', + NOW() + INTERVAL '10 minutes', + 'challenge', + 'S256', + false, + 'https://example.com/after-auth', + NOW(), + NOW() + ) + `; + + return { tenancyId, projectUserId, authorizationCode }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const existing = await sql` + SELECT "grantedRefreshTokenId" + FROM "ProjectUserAuthorizationCode" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "authorizationCode" = ${ctx.authorizationCode} + `; + expect(existing).toHaveLength(1); + expect(existing[0].grantedRefreshTokenId).toBeNull(); + + const grantedRefreshTokenId = randomUUID(); + await sql` + UPDATE "ProjectUserAuthorizationCode" + SET "grantedRefreshTokenId" = ${grantedRefreshTokenId}::uuid + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "authorizationCode" = ${ctx.authorizationCode} + `; + + const updated = await sql` + SELECT "grantedRefreshTokenId" + FROM "ProjectUserAuthorizationCode" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "authorizationCode" = ${ctx.authorizationCode} + `; + expect(updated).toHaveLength(1); + expect(updated[0].grantedRefreshTokenId).toBe(grantedRefreshTokenId); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 75c8561bb1..2ffb2919fc 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -68,10 +68,10 @@ model Tenancy { branchId String // If organizationId is NULL, hasNoOrganization must be TRUE. If organizationId is not NULL, hasNoOrganization must be NULL. - organizationId String? @db.Uuid - hasNoOrganization BooleanTrue? - emailOutboxes EmailOutbox[] - sessionReplays SessionReplay[] + organizationId String? @db.Uuid + hasNoOrganization BooleanTrue? + emailOutboxes EmailOutbox[] + sessionReplays SessionReplay[] sessionReplayChunks SessionReplayChunk[] managedEmailDomains ManagedEmailDomain[] @@ -90,24 +90,24 @@ enum ManagedEmailDomainStatus { model ManagedEmailDomain { id String @id @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + tenancyId String @db.Uuid + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) projectId String - branchId String + branchId String - subdomain String - senderLocalPart String - resendDomainId String @unique + subdomain String + senderLocalPart String + resendDomainId String @unique nameServerRecords Json - status ManagedEmailDomainStatus @default(PENDING_VERIFICATION) + status ManagedEmailDomainStatus @default(PENDING_VERIFICATION) providerStatusRaw String? - isActive Boolean @default(true) - lastError String? - verifiedAt DateTime? - appliedAt DateTime? - lastWebhookAt DateTime? + isActive Boolean @default(true) + lastError String? + verifiedAt DateTime? + appliedAt DateTime? + lastWebhookAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -348,18 +348,18 @@ model SessionReplay { chunks SessionReplayChunk[] @@id([tenancyId, id]) - @@map("SessionReplay") @@index([tenancyId, projectUserId, startedAt]) @@index([tenancyId, lastEventAt]) // index by updatedAt instead of lastEventAt because event timing can be spoofed @@index([tenancyId, refreshTokenId, updatedAt]) + @@map("SessionReplay") } model SessionReplayChunk { id String @id @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - sessionReplayId String @db.Uuid @map("sessionReplayId") + tenancyId String @db.Uuid + sessionReplayId String @map("sessionReplayId") @db.Uuid // Unique per uploaded batch for a given session id. batchId String @db.Uuid @@ -383,8 +383,8 @@ model SessionReplayChunk { tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) @@unique([tenancyId, sessionReplayId, batchId]) - @@map("SessionReplayChunk") @@index([tenancyId, sessionReplayId, createdAt]) + @@map("SessionReplayChunk") } enum ContactChannelType { @@ -612,6 +612,9 @@ model ProjectUserAuthorizationCode { newUser Boolean afterCallbackRedirectUrl String? + /// Refresh token ID that should be granted when this authorization code is exchanged. + /// NULL means no specific refresh token is preselected, so the token endpoint follows normal issuance/reuse logic. + grantedRefreshTokenId String? @db.Uuid @@id([tenancyId, authorizationCode]) } diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index aca1a9b49e..90a26f24a4 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -1,4 +1,5 @@ import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; +import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { decodeAccessToken, oauthCookieSchema } from "@/lib/tokens"; import { getProjectBranchFromClientId, getProvider } from "@/oauth"; @@ -35,7 +36,7 @@ export const GET = createSmartRouteHandler({ */ error_redirect_url: urlSchema.optional().meta({ openapiField: { hidden: true } }), error_redirect_uri: urlSchema.optional(), - after_callback_redirect_url: yupString().optional(), + after_callback_redirect_url: urlSchema.optional(), // oauth parameters client_id: yupString().defined(), @@ -75,6 +76,13 @@ export const GET = createSmartRouteHandler({ if (query.type === "link" && !query.token) { throw new StatusError(StatusError.BadRequest, "?token= query parameter is required for link type"); } + if ( + query.after_callback_redirect_url + && !validateRedirectUrl(query.after_callback_redirect_url, tenancy) + && !isAcceptedNativeAppUrl(query.after_callback_redirect_url) + ) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } // If a token is provided, store it in the outer info so we can use it to link another user to the account, or to upgrade an anonymous user let projectUserId: string | undefined; diff --git a/apps/backend/src/app/api/latest/auth/oauth/cross-domain/authorize/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/cross-domain/authorize/route.tsx new file mode 100644 index 0000000000..bca56f71fc --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/oauth/cross-domain/authorize/route.tsx @@ -0,0 +1,174 @@ +import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; +import { Tenancy } from "@/lib/tenancies"; +import { isRefreshTokenValid } from "@/lib/tokens"; +import { oauthServer } from "@/oauth"; +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, urlSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { publishableClientKeyNotNecessarySentinel } from "@stackframe/stack-shared/dist/utils/oauth"; +import { InvalidClientError, InvalidScopeError, Request as OAuthRequest, Response as OAuthResponse } from "@node-oauth/oauth2-server"; + +type CrossDomainAuthorizeBody = { + redirect_uri: string, + state: string, + code_challenge: string, + code_challenge_method: "S256", + after_callback_redirect_url?: string, +}; + +export async function createCrossDomainAuthorizeRedirect(options: { + tenancy: Tenancy, + user: { id: string, refreshTokenId: string } | null, + publishableClientKey: string, + body: CrossDomainAuthorizeBody, +}): Promise { + const { tenancy, user, body } = options; + if (!user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if ( + !validateRedirectUrl(body.redirect_uri, tenancy) && + !isAcceptedNativeAppUrl(body.redirect_uri) + ) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } + if ( + body.after_callback_redirect_url && + !validateRedirectUrl(body.after_callback_redirect_url, tenancy) && + !isAcceptedNativeAppUrl(body.after_callback_redirect_url) + ) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } + const oauthRequest = new OAuthRequest({ + headers: {}, + body: {}, + method: "GET", + query: { + client_id: tenancy.branchId === "main" ? tenancy.project.id : `${tenancy.project.id}#${tenancy.branchId}`, + client_secret: options.publishableClientKey, + redirect_uri: body.redirect_uri, + scope: "legacy", + state: body.state, + grant_type: "authorization_code", + code_challenge: body.code_challenge, + code_challenge_method: body.code_challenge_method, + response_type: "code", + }, + }); + const oauthResponse = new OAuthResponse(); + try { + await oauthServer.authorize( + oauthRequest, + oauthResponse, + { + authenticateHandler: { + handle: async () => ({ + id: user.id, + refreshTokenId: user.refreshTokenId, + newUser: false, + afterCallbackRedirectUrl: body.after_callback_redirect_url, + }), + }, + }, + ); + } catch (error) { + if (error instanceof InvalidClientError) { + throw new KnownErrors.InvalidOAuthClientIdOrSecret(); + } + if (error instanceof InvalidScopeError) { + throw new StatusError(400, "Invalid scope requested."); + } + throw error; + } + + const redirectUrl = oauthResponse.headers?.location; + if (typeof redirectUrl !== "string") { + throw new StackAssertionError("Cross-domain authorization response is missing redirect location", { + oauthResponse, + }); + } + return redirectUrl; +} + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Create cross-domain auth handoff redirect", + description: "Creates a one-time OAuth authorization code redirect for cross-domain sign-in handoff using PKCE.", + tags: ["Oauth"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + user: adaptSchema.optional(), + refreshTokenId: adaptSchema.optional(), + }).defined(), + headers: yupObject({ + "x-stack-publishable-client-key": yupTuple([yupString().defined()]).optional(), + "x-stack-refresh-token": yupTuple([yupString().defined()]).optional(), + }).defined(), + body: yupObject({ + redirect_uri: urlSchema.defined(), + state: yupString().min(1).max(512).defined(), + code_challenge: yupString().matches(/^[A-Za-z0-9._~-]{43,128}$/).defined(), + code_challenge_method: yupString().oneOf(["S256"]).default("S256"), + after_callback_redirect_url: urlSchema.optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + redirect_url: urlSchema.defined(), + }).defined(), + }), + async handler({ auth: { tenancy, user, refreshTokenId }, headers, body }) { + let userWithSession: { id: string, refreshTokenId: string } | null = null; + if (!user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (!refreshTokenId) { + throw new StatusError(400, "Cross-domain auth handoff requires a refresh-token-bound session. Please sign in again."); + } + const providedRefreshToken = headers["x-stack-refresh-token"]?.[0]; + if (!providedRefreshToken) { + throw new StatusError(400, "Cross-domain auth handoff requires passing the current refresh token."); + } + const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findUnique({ + where: { + refreshToken: providedRefreshToken, + }, + }); + if ( + !refreshTokenObj + || refreshTokenObj.id !== refreshTokenId + || refreshTokenObj.projectUserId !== user.id + || refreshTokenObj.tenancyId !== tenancy.id + ) { + throw new StatusError(401, "Cross-domain auth handoff refresh token does not match the authenticated session."); + } + if (!await isRefreshTokenValid({ tenancy, refreshTokenObj })) { + throw new StatusError(401, "Cross-domain auth handoff refresh token is not valid."); + } + userWithSession = { + id: user.id, + refreshTokenId: refreshTokenObj.id, + }; + const redirectUrl = await createCrossDomainAuthorizeRedirect({ + tenancy, + user: userWithSession, + publishableClientKey: headers["x-stack-publishable-client-key"]?.[0] ?? publishableClientKeyNotNecessarySentinel, + body, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + redirect_url: redirectUrl, + }, + }; + }, +}); diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index 841300b42f..9e749796b1 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -8,7 +8,7 @@ import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefres import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken, Token, User } from "@node-oauth/oauth2-server"; import { KnownErrors } from "@stackframe/stack-shared"; -import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { getProjectBranchFromClientId } from "."; const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError; @@ -123,6 +123,12 @@ export class OAuthModel implements AuthorizationCodeModel { }, }, }); + if (refreshTokenObj && refreshTokenObj.projectUserId !== user.id) { + throw new StackAssertionError("Cross-domain handoff refresh token does not belong to the authenticated user", { + refreshTokenProjectUserId: refreshTokenObj.projectUserId, + userId: user.id, + }); + } if (refreshTokenObj && await isRefreshTokenValid({ tenancy, refreshTokenObj })) { return refreshTokenObj; } @@ -145,6 +151,8 @@ export class OAuthModel implements AuthorizationCodeModel { } async saveToken(token: Token, client: Client, user: User): Promise { + const afterCallbackRedirectUrl = user.afterCallbackRedirectUrl; + if (token.refreshToken) { const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); const prisma = await getPrismaClientForTenancy(tenancy); @@ -199,8 +207,8 @@ export class OAuthModel implements AuthorizationCodeModel { // TODO remove deprecated camelCase properties newUser: user.newUser, is_new_user: user.newUser, - afterCallbackRedirectUrl: user.afterCallbackRedirectUrl, - after_callback_redirect_url: user.afterCallbackRedirectUrl, + afterCallbackRedirectUrl: afterCallbackRedirectUrl, + after_callback_redirect_url: afterCallbackRedirectUrl, }; } @@ -297,6 +305,7 @@ export class OAuthModel implements AuthorizationCodeModel { projectUserId: user.id, newUser: user.newUser, afterCallbackRedirectUrl: user.afterCallbackRedirectUrl, + grantedRefreshTokenId: user.refreshTokenId, tenancyId: tenancy.id, }, }); @@ -361,7 +370,8 @@ export class OAuthModel implements AuthorizationCodeModel { user: { id: code.projectUserId, newUser: code.newUser, - afterCallbackRedirectUrl: code.afterCallbackRedirectUrl, + afterCallbackRedirectUrl: code.afterCallbackRedirectUrl ?? undefined, + refreshTokenId: code.grantedRefreshTokenId ?? undefined, }, }; } diff --git a/apps/dashboard/.env.development b/apps/dashboard/.env.development index 4b14a42eaf..63ba5cb677 100644 --- a/apps/dashboard/.env.development +++ b/apps/dashboard/.env.development @@ -1,5 +1,6 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02 NEXT_PUBLIC_STACK_DOCS_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 +NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09 NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=false NEXT_PUBLIC_STACK_PROJECT_ID=internal diff --git a/apps/e2e/.env.development b/apps/e2e/.env.development index 42b681a546..c764d48b4b 100644 --- a/apps/e2e/.env.development +++ b/apps/e2e/.env.development @@ -4,6 +4,7 @@ STACK_INTERNAL_PROJECT_ID=internal STACK_INTERNAL_PROJECT_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only STACK_INTERNAL_PROJECT_SERVER_KEY=this-secret-server-key-is-for-local-development-only STACK_INTERNAL_PROJECT_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only +NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09 STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe STACK_INBUCKET_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}05 diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts index b17628eccf..2f837233eb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/authorize.test.ts @@ -198,3 +198,58 @@ it("should fail if an invalid redirect URL is provided", async ({ expect }) => { } `); }); + +it("should fail if an invalid after_callback_redirect_url is provided", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", { + redirect: "manual", + query: { + ...await Auth.OAuth.getAuthorizeQuery(), + after_callback_redirect_url: "not-a-valid-url", + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "SCHEMA_ERROR", + "details": { + "message": deindent\` + Request validation failed on GET /api/v1/auth/oauth/authorize/spotify: + - query.after_callback_redirect_url is not a valid URL + \`, + }, + "error": deindent\` + Request validation failed on GET /api/v1/auth/oauth/authorize/spotify: + - query.after_callback_redirect_url is not a valid URL + \`, + }, + "headers": Headers { + "x-stack-known-error": "SCHEMA_ERROR", +