From 0e3f63d2aca01dcc00dc0cd95ebf8d5944f79ff4 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 12 Mar 2025 18:44:33 -0700 Subject: [PATCH] Add CLI authentication endpoints (#503) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Konstantin Wohlwend Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: TheCactusBlue --- .../20250303231152_add_cli_auth/migration.sql | 23 ++++ apps/backend/prisma/schema.prisma | 18 +++ .../api/latest/auth/cli/complete/route.tsx | 61 +++++++++ .../app/api/latest/auth/cli/poll/route.tsx | 79 +++++++++++ .../src/app/api/latest/auth/cli/route.tsx | 55 ++++++++ .../backend/src/app/api/latest/users/crud.tsx | 15 ++- apps/backend/src/lib/tenancies.tsx | 3 +- .../public/stack_auth_cli_template.py | 56 ++++++++ .../api/v1/auth/cli/complete.test.ts | 123 ++++++++++++++++++ .../endpoints/api/v1/auth/cli/poll.test.ts | 81 ++++++++++++ .../endpoints/api/v1/auth/cli/route.test.ts | 45 +++++++ docs/fern/docs-template.yml | 6 + .../others/cli-authentication.mdx | 63 +++++++++ .../src/interface/clientInterface.ts | 27 ++++ packages/stack-shared/src/known-errors.tsx | 12 ++ .../src/components-page/cli-auth-confirm.tsx | 111 ++++++++++++++++ .../src/components-page/stack-handler.tsx | 10 ++ 17 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250303231152_add_cli_auth/migration.sql create mode 100644 apps/backend/src/app/api/latest/auth/cli/complete/route.tsx create mode 100644 apps/backend/src/app/api/latest/auth/cli/poll/route.tsx create mode 100644 apps/backend/src/app/api/latest/auth/cli/route.tsx create mode 100644 apps/dashboard/public/stack_auth_cli_template.py create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/cli/complete.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/cli/poll.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/cli/route.test.ts create mode 100644 docs/fern/docs/pages-template/others/cli-authentication.mdx create mode 100644 packages/template/src/components-page/cli-auth-confirm.tsx diff --git a/apps/backend/prisma/migrations/20250303231152_add_cli_auth/migration.sql b/apps/backend/prisma/migrations/20250303231152_add_cli_auth/migration.sql new file mode 100644 index 000000000..0932ccb91 --- /dev/null +++ b/apps/backend/prisma/migrations/20250303231152_add_cli_auth/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "CliAuthAttempt" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL, + "pollingCode" TEXT NOT NULL, + "loginCode" TEXT NOT NULL, + "refreshToken" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CliAuthAttempt_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CliAuthAttempt_pollingCode_key" ON "CliAuthAttempt"("pollingCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "CliAuthAttempt_loginCode_key" ON "CliAuthAttempt"("loginCode"); + +-- AddForeignKey +ALTER TABLE "CliAuthAttempt" ADD CONSTRAINT "CliAuthAttempt_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c1abeba15..6102f887b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -83,6 +83,7 @@ model Tenancy { contactChannels ContactChannel[] @relation("TenancyContactChannels") connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts") SentEmail SentEmail[] + cliAuthAttempts CliAuthAttempt[] @@unique([projectId, branchId, organizationId]) @@unique([projectId, branchId, hasNoOrganization]) @@ -959,3 +960,20 @@ model SentEmail { @@id([tenancyId, id]) } + +model CliAuthAttempt { + tenancyId String @db.Uuid + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + id String @default(uuid()) @db.Uuid + pollingCode String @unique + loginCode String @unique + refreshToken String? + expiresAt DateTime + usedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([tenancyId, id]) +} diff --git a/apps/backend/src/app/api/latest/auth/cli/complete/route.tsx b/apps/backend/src/app/api/latest/auth/cli/complete/route.tsx new file mode 100644 index 000000000..cf6a01c61 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/cli/complete/route.tsx @@ -0,0 +1,61 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Complete CLI authentication", + description: "Set the refresh token for a CLI authentication session using the login code", + tags: ["CLI Authentication"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + login_code: yupString().defined(), + refresh_token: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + async handler({ auth: { tenancy }, body: { login_code, refresh_token } }) { + // Find the CLI auth attempt + const cliAuth = await prismaClient.cliAuthAttempt.findFirst({ + where: { + tenancyId: tenancy.id, + loginCode: login_code, + refreshToken: null, + expiresAt: { + gt: new Date(), + }, + }, + }); + + if (!cliAuth) { + throw new StatusError(400, "Invalid login code or the code has expired"); + } + + // Update with refresh token + await prismaClient.cliAuthAttempt.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: cliAuth.id, + }, + }, + data: { + refreshToken: refresh_token, + }, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx b/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx new file mode 100644 index 000000000..2cfded92e --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx @@ -0,0 +1,79 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +// Helper function to create response +const createResponse = (status: 'waiting' | 'success' | 'expired' | 'used', refreshToken?: string) => ({ + statusCode: status === 'success' ? 201 : 200, + bodyType: "json" as const, + body: { + status, + ...(refreshToken && { refresh_token: refreshToken }), + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Poll CLI authentication status", + description: "Check the status of a CLI authentication session using the polling code", + tags: ["CLI Authentication"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + polling_code: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200, 201]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["waiting", "success", "expired", "used"]).defined(), + refresh_token: yupString().optional(), + }).defined(), + }), + async handler({ auth: { tenancy }, body: { polling_code } }) { + // Find the CLI auth attempt + const cliAuth = await prismaClient.cliAuthAttempt.findFirst({ + where: { + tenancyId: tenancy.id, + pollingCode: polling_code, + }, + }); + + if (!cliAuth) { + throw new KnownErrors.InvalidPollingCodeError(); + } + + if (cliAuth.expiresAt < new Date()) { + return createResponse('expired'); + } + + if (cliAuth.usedAt) { + return createResponse('used'); + } + + if (!cliAuth.refreshToken) { + return createResponse('waiting'); + } + + // Mark as used + await prismaClient.cliAuthAttempt.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: cliAuth.id, + }, + }, + data: { + usedAt: new Date(), + }, + }); + + return createResponse('success', cliAuth.refreshToken); + }, +}); diff --git a/apps/backend/src/app/api/latest/auth/cli/route.tsx b/apps/backend/src/app/api/latest/auth/cli/route.tsx new file mode 100644 index 000000000..c769bfe90 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/cli/route.tsx @@ -0,0 +1,55 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: 'Initiate CLI authentication', + description: 'Create a new CLI authentication session and return polling and login codes', + tags: ['CLI Authentication'], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours + }).default({}), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(['json']).defined(), + body: yupObject({ + polling_code: yupString().defined(), + login_code: yupString().defined(), + expires_at: yupString().defined(), + }).defined(), + }), + async handler({ auth: { tenancy }, body: { expires_in_millis } }) { + const pollingCode = generateSecureRandomString(); + const loginCode = generateSecureRandomString(); + const expiresAt = new Date(Date.now() + expires_in_millis); + + // Create a new CLI auth attempt + const cliAuth = await prismaClient.cliAuthAttempt.create({ + data: { + tenancyId: tenancy.id, + pollingCode, + loginCode, + expiresAt, + }, + }); + + return { + statusCode: 200, + bodyType: 'json', + body: { + polling_code: cliAuth.pollingCode, + login_code: cliAuth.loginCode, + expires_at: cliAuth.expiresAt.toISOString(), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index fd09c3f55..cebadea3c 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -415,10 +415,17 @@ export async function getUser(options: { userId: string } & ({ projectId: string if (!getNodeEnvironment().includes("prod")) { const legacyResult = await getUserLegacy({ tenancyId: tenancy.id, userId: options.userId }); if (!deepPlainEquals(result, legacyResult)) { - throw new StackAssertionError("User result mismatch", { - result, - legacyResult, - }); + // Coincidentally, it can happen that a user is modified in the database right between these two queries. + // While unlikely, it makes the tests flakey sometimes, so let's make sure that requesting the raw query again + // still causes the same mismatch. + const newResult = await rawQuery(getUserQuery(tenancy.project.id, tenancy.branchId, options.userId)); + if (!deepPlainEquals(newResult, legacyResult)) { + throw new StackAssertionError("User result mismatch", { + result, + legacyResult, + newResult, + }); + } } } diff --git a/apps/backend/src/lib/tenancies.tsx b/apps/backend/src/lib/tenancies.tsx index b811df807..5cb94d5ce 100644 --- a/apps/backend/src/lib/tenancies.tsx +++ b/apps/backend/src/lib/tenancies.tsx @@ -1,6 +1,7 @@ import { prismaClient } from "@/prisma-client"; import { Prisma } from "@prisma/client"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; +import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { fullProjectInclude, getProject, projectPrismaToCrud } from "./projects"; @@ -65,7 +66,7 @@ export async function getSoleTenancyFromProject(projectOrId: ProjectsCrud["Admin if (returnNullIfNotFound) return null; throw new StackAssertionError(`Project ${projectOrId} does not exist`, { projectOrId }); } - const tenancyId = soleTenancyIdsCache.get(project.id) ?? (await getTenancyFromProject(project.id, 'main', null))?.id; + const tenancyId = (!getNodeEnvironment().includes('development') ? soleTenancyIdsCache.get(project.id) : null) ?? (await getTenancyFromProject(project.id, 'main', null))?.id; if (!tenancyId) { if (returnNullIfNotFound) return null; throw new StackAssertionError(`No tenancy found for project ${project.id}`, { project }); diff --git a/apps/dashboard/public/stack_auth_cli_template.py b/apps/dashboard/public/stack_auth_cli_template.py new file mode 100644 index 000000000..126d19b35 --- /dev/null +++ b/apps/dashboard/public/stack_auth_cli_template.py @@ -0,0 +1,56 @@ +import time +import requests +import webbrowser +import urllib.parse + +def prompt_cli_login( + *, + base_url: str = "https://api.stack-auth.com", + app_url: str, + project_id: str, + publishable_client_key: str, +): + if not app_url: + raise Exception("app_url is required and must be set to the URL of the app you're authenticating with") + if not project_id: + raise Exception("project_id is required") + if not publishable_client_key: + raise Exception("publishable_client_key is required") + + def post(endpoint, json): + return requests.request( + 'POST', + f'{base_url}{endpoint}', + headers={ + 'Content-Type': 'application/json', + 'x-stack-project-id': project_id, + 'x-stack-access-type': 'client', + 'x-stack-publishable-client-key': publishable_client_key, + }, + json=json, + ) + + # Step 1: Initiate the CLI auth process + init = post('/api/v1/auth/cli', { + 'expires_in_millis': 10 * 60 * 1000, + }) + if init.status_code != 200: + raise Exception(f"Failed to initiate CLI auth: {init.status_code} {init.text}") + polling_code = init.json()['polling_code'] + login_code = init.json()['login_code'] + + # Step 2: Open the browser for the user to authenticate + url = f'{app_url}/handler/cli-auth-confirm?login_code={urllib.parse.quote(login_code)}' + print(f"Opening browser to authenticate. If it doesn't open automatically, please visit:\n{url}") + webbrowser.open(url) + + # Step 3: Retrieve the token + while True: + status = post('/api/v1/auth/cli/poll', { + 'polling_code': polling_code, + }) + if status.status_code != 200 and status.status_code != 201: + raise Exception(f"Failed to get CLI auth status: {status.status_code} {status.text}") + if status.json()['status'] == 'success': + return status.json()['refresh_token'] + time.sleep(2) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/cli/complete.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/cli/complete.test.ts new file mode 100644 index 000000000..a941cbdee --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/cli/complete.test.ts @@ -0,0 +1,123 @@ +import { it } from "../../../../../../helpers"; +import { niceBackendFetch } from "../../../../../backend-helpers"; + +it("should set the refresh token for a CLI auth attempt and return success when polling", async ({ expect }) => { + // First, create a new CLI auth attempt + const createResponse = await niceBackendFetch("/api/latest/auth/cli", { + method: "POST", + accessType: "server", + body: {}, + }); + + const refreshToken = "test-refresh-token"; + + // Then set the refresh token + const loginResponse = await niceBackendFetch("/api/latest/auth/cli/complete", { + method: "POST", + accessType: "server", + body: { login_code: createResponse.body.login_code, refresh_token: refreshToken }, + }); + expect(loginResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": true }, + "headers": Headers {