Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/commands/pr-comments-review.md
Original file line number Diff line number Diff line change
@@ -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!
1 change: 1 addition & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "ProjectUserAuthorizationCode"
ADD COLUMN "grantedRefreshTokenId" UUID;
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof preMigration>>) => {
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);
};
43 changes: 23 additions & 20 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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])
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading