diff --git a/.cursor/commands/pr-comments-review.md b/.cursor/commands/pr-comments-review.md index bc9d8784f3..b8e4bcc58a 100644 --- a/.cursor/commands/pr-comments-review.md +++ b/.cursor/commands/pr-comments-review.md @@ -1 +1 @@ -Please review the PR comments with the `gh` CLI and fix those issues that are valid and relevant. Resolve the comments when you fix them. Also resolve all those comments that no longer exist or have already been resolved. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail. Do NOT automatically commit or stage the changes back to the PR! +Please review the PR comments with the `gh` CLI and fix those issues that are valid and relevant. Resolve the comments when you fix them. Also resolve all those comments that no longer exist or have already been resolved. Leave those comments that are mostly bullshit unresolved. Also, create or modify tests to make sure the fixed behavior works as expected. Report the result to me in detail (for the most important issues, whether resolved or unresolved, mark them with a ‼️ emoji). Do NOT automatically commit or stage the changes back to the PR! diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 8b9329ce29..4ad9528b8c 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 ecb4a8c512..48403f9daf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -69,10 +69,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[] @@ -94,24 +94,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 @@ -367,18 +367,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 @@ -402,8 +402,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 { @@ -631,6 +631,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 ef2c23292e..853ddc71fb 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,11 +1,12 @@ 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 { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile"; +import { botChallengeFlowRequestSchemaFields, getRequestContextAndBotChallengeAssessment } from "@/lib/turnstile"; import { getProjectBranchFromClientId, getProvider } from "@/oauth"; import { globalPrismaClient } from "@/prisma-client"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import type { SmartResponse } from "@/route-handlers/smart-response"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { urlSchema, yupArray, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; @@ -37,7 +38,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(), stack_response_mode: yupString().oneOf(["json", "redirect"]).default("redirect"), ...botChallengeFlowRequestSchemaFields, @@ -93,6 +94,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(); + } const { turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(query, "oauth_authenticate", tenancy); 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..972bd52358 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/oauth/cross-domain/authorize/route.tsx @@ -0,0 +1,184 @@ +import { checkApiKeySet, throwCheckApiKeySetError } from "@/lib/internal-api-keys"; +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 publishableClientKey = headers["x-stack-publishable-client-key"]?.[0] ?? publishableClientKeyNotNecessarySentinel; + const keyCheck = await checkApiKeySet(tenancy.project.id, { publishableClientKey }); + if (keyCheck.status === "error") { + throwCheckApiKeySetError( + keyCheck.error, + tenancy.project.id, + new KnownErrors.InvalidPublishableClientKey(tenancy.project.id), + ); + } + const redirectUrl = await createCrossDomainAuthorizeRedirect({ + tenancy, + user: userWithSession, + publishableClientKey, + 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..4953c2331b 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, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { getProjectBranchFromClientId } from "."; const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError; @@ -123,6 +123,9 @@ export class OAuthModel implements AuthorizationCodeModel { }, }, }); + if (refreshTokenObj && refreshTokenObj.projectUserId !== user.id) { + throw new StatusError(401, "Cross-domain handoff refresh token does not belong to the authenticated user."); + } if (refreshTokenObj && await isRefreshTokenValid({ tenancy, refreshTokenObj })) { return refreshTokenObj; } @@ -145,6 +148,8 @@ export class OAuthModel implements AuthorizationCodeModel { } async saveToken(token: Token, client: Client, user: User): Promise { + const afterCallbackRedirectUrl = user.afterCallbackRedirectUrl ?? null; + if (token.refreshToken) { const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); const prisma = await getPrismaClientForTenancy(tenancy); @@ -199,8 +204,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 +302,7 @@ export class OAuthModel implements AuthorizationCodeModel { projectUserId: user.id, newUser: user.newUser, afterCallbackRedirectUrl: user.afterCallbackRedirectUrl, + grantedRefreshTokenId: user.refreshTokenId, tenancyId: tenancy.id, }, }); @@ -361,7 +367,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 c431a5ee70..0a16ee1c92 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 55f091f898..ad3905c9de 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 @@ -217,3 +217,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", +