mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Add CLI authentication endpoints (#503)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: TheCactusBlue <thecactusblue@gmail.com>
This commit is contained in:
parent
88a81be8c4
commit
0e3f63d2ac
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
61
apps/backend/src/app/api/latest/auth/cli/complete/route.tsx
Normal file
61
apps/backend/src/app/api/latest/auth/cli/complete/route.tsx
Normal file
@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
79
apps/backend/src/app/api/latest/auth/cli/poll/route.tsx
Normal file
79
apps/backend/src/app/api/latest/auth/cli/poll/route.tsx
Normal file
@ -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);
|
||||
},
|
||||
});
|
||||
55
apps/backend/src/app/api/latest/auth/cli/route.tsx
Normal file
55
apps/backend/src/app/api/latest/auth/cli/route.tsx
Normal file
@ -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(),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
56
apps/dashboard/public/stack_auth_cli_template.py
Normal file
56
apps/dashboard/public/stack_auth_cli_template.py
Normal file
@ -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)
|
||||
@ -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 { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Then poll for the status
|
||||
const pollResponse = await niceBackendFetch("/api/latest/auth/cli/poll", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { polling_code: createResponse.body.polling_code },
|
||||
});
|
||||
|
||||
expect(pollResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"refresh_token": <stripped field 'refresh_token'>,
|
||||
"status": "success",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// // Polling again should return 'used' status
|
||||
|
||||
const pollResponse2 = await niceBackendFetch("/api/latest/auth/cli/poll", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { polling_code: createResponse.body.polling_code },
|
||||
});
|
||||
|
||||
expect(pollResponse2).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "status": "used" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return an error when trying to set the refresh token with an invalid login code", async ({ expect }) => {
|
||||
const refreshToken = "test-refresh-token";
|
||||
|
||||
// Try to set the refresh token with an invalid login code
|
||||
const loginResponse = await niceBackendFetch("/api/latest/auth/cli/complete", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { login_code: "invalid-login-code", refresh_token: refreshToken },
|
||||
});
|
||||
|
||||
expect(loginResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "Invalid login code or the code has expired",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not allow setting the refresh token twice", async ({ expect }) => {
|
||||
// First, create a new CLI auth attempt
|
||||
const createResponse = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {},
|
||||
});
|
||||
|
||||
const loginCode = createResponse.body.login_code;
|
||||
const refreshToken1 = "test-refresh-token-1";
|
||||
const refreshToken2 = "test-refresh-token-2";
|
||||
|
||||
// Set the refresh token the first time
|
||||
const loginResponse1 = await niceBackendFetch("/api/latest/auth/cli/complete", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { login_code: loginCode, refresh_token: refreshToken1 },
|
||||
});
|
||||
|
||||
expect(loginResponse1).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "success": true },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Try to set the refresh token again
|
||||
const loginResponse2 = await niceBackendFetch("/api/latest/auth/cli/complete", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { login_code: loginCode, refresh_token: refreshToken2 },
|
||||
});
|
||||
|
||||
expect(loginResponse2).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "Invalid login code or the code has expired",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
it("should return 'waiting' status when polling for a new CLI auth attempt", async ({ expect }) => {
|
||||
// First, create a new CLI auth attempt
|
||||
const createResponse = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {},
|
||||
});
|
||||
|
||||
const pollingCode = createResponse.body.polling_code;
|
||||
|
||||
// Then poll for the status
|
||||
const pollResponse = await niceBackendFetch("/api/latest/auth/cli/poll", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { polling_code: pollingCode },
|
||||
});
|
||||
|
||||
expect(pollResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "status": "waiting" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 with INVALID_POLLING_CODE error when polling with an invalid code", async ({ expect }) => {
|
||||
const pollResponse = await niceBackendFetch("/api/latest/auth/cli/poll", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { polling_code: "invalid-code" },
|
||||
});
|
||||
|
||||
expect(pollResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "INVALID_POLLING_CODE",
|
||||
"error": "The polling code is invalid or does not exist.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INVALID_POLLING_CODE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 'expired' status when polling for an expired CLI auth attempt", async ({ expect }) => {
|
||||
// First, create a new CLI auth attempt with a very short expiration time
|
||||
const createResponse = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
expires_in_millis: 1000, // 1 second
|
||||
},
|
||||
});
|
||||
|
||||
const pollingCode = createResponse.body.polling_code;
|
||||
|
||||
// Wait for the CLI auth attempt to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5 seconds
|
||||
|
||||
// Then poll for the status
|
||||
const pollResponse = await niceBackendFetch("/api/latest/auth/cli/poll", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: { polling_code: pollingCode },
|
||||
});
|
||||
|
||||
expect(pollResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "status": "expired" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { it } from "../../../../../../helpers";
|
||||
import { niceBackendFetch } from "../../../../../backend-helpers";
|
||||
|
||||
it("should create a new CLI auth attempt", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty("polling_code");
|
||||
expect(response.body).toHaveProperty("login_code");
|
||||
expect(response.body).toHaveProperty("expires_at");
|
||||
|
||||
// Verify that the expiration time is about 2 hours from now
|
||||
const expiresAt = new Date(response.body.expires_at);
|
||||
const now = new Date();
|
||||
const twoHoursInMs = 2 * 60 * 60 * 1000;
|
||||
expect(expiresAt.getTime() - now.getTime()).toBeGreaterThan(twoHoursInMs - 10000); // Allow for a small margin of error
|
||||
expect(expiresAt.getTime() - now.getTime()).toBeLessThan(twoHoursInMs + 10000); // Allow for a small margin of error
|
||||
});
|
||||
|
||||
it("should create a new CLI auth attempt with custom expiration time", async ({ expect }) => {
|
||||
const customExpirationMs = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const response = await niceBackendFetch("/api/latest/auth/cli", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
expires_in_millis: customExpirationMs,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty("polling_code");
|
||||
expect(response.body).toHaveProperty("login_code");
|
||||
expect(response.body).toHaveProperty("expires_at");
|
||||
|
||||
// Verify that the expiration time is about 30 minutes from now
|
||||
const expiresAt = new Date(response.body.expires_at);
|
||||
const now = new Date();
|
||||
expect(expiresAt.getTime() - now.getTime()).toBeGreaterThan(customExpirationMs - 10000); // Allow for a small margin of error
|
||||
expect(expiresAt.getTime() - now.getTime()).toBeLessThan(customExpirationMs + 10000); // Allow for a small margin of error
|
||||
});
|
||||
@ -120,6 +120,12 @@ navigation:
|
||||
- page: Self-Hosting
|
||||
icon: fa-regular fa-house-laptop
|
||||
path: ./docs/pages-{platform}/others/self-host.mdx
|
||||
- section: Others
|
||||
platform: python-like
|
||||
contents:
|
||||
- page: CLI Authentication
|
||||
icon: fa-regular fa-terminal
|
||||
path: ./docs/pages-{platform}/others/cli-authentication.mdx
|
||||
- tab: components
|
||||
platform: react-like
|
||||
layout:
|
||||
|
||||
63
docs/fern/docs/pages-template/others/cli-authentication.mdx
Normal file
63
docs/fern/docs/pages-template/others/cli-authentication.mdx
Normal file
@ -0,0 +1,63 @@
|
||||
---
|
||||
title: CLI Authentication
|
||||
description: How to authenticate a command line application using Stack Auth
|
||||
---
|
||||
|
||||
If you're building a command line application that runs in a terminal, you can use Stack Auth to let your users log in to their accounts.
|
||||
|
||||
To do so, we provide a Python template that you can use as a starting point. [Download it here](https://app.stack-auth.com/stack_auth_cli_template.py) and copy it into your project, for example:
|
||||
|
||||
```py
|
||||
└─ my-python-app
|
||||
├─ main.py
|
||||
└─ stack_auth_cli_template.py # <- the file you just downloaded
|
||||
```
|
||||
|
||||
Then, you can import the `prompt_cli_login` function:
|
||||
|
||||
```py
|
||||
from stack_auth_cli_template import prompt_cli_login
|
||||
|
||||
# prompt the user to log in
|
||||
refresh_token = prompt_cli_login(
|
||||
app_url="https://your-app-url.example.com",
|
||||
project_id="your-project-id-here",
|
||||
publishable_client_key="your-publishable-client-key-here",
|
||||
)
|
||||
|
||||
if refresh_token is None:
|
||||
print("User cancelled the login process. Exiting")
|
||||
exit(1)
|
||||
|
||||
# you can also store the refresh token in a file, and only prompt the user to log in if the file doesn't exist
|
||||
|
||||
# you can now use the REST API with the refresh token
|
||||
def stack_auth_request(method, endpoint, **kwargs):
|
||||
# ... see Stack Auth's Getting Started section to see how this function should look like
|
||||
# https://docs.stack-auth.com/python/getting-started/setup
|
||||
|
||||
def get_access_token(refresh_token):
|
||||
access_token_response = stack_auth_request(
|
||||
'post',
|
||||
'/api/v1/auth/sessions/current/refresh',
|
||||
headers={
|
||||
'x-stack-refresh-token': refresh_token,
|
||||
}
|
||||
)
|
||||
|
||||
return access_token_response['access_token']
|
||||
|
||||
def get_user_object(access_token):
|
||||
return stack_auth_request(
|
||||
'get',
|
||||
'/api/v1/users/me',
|
||||
headers={
|
||||
'x-stack-access-token': access_token,
|
||||
}
|
||||
)
|
||||
|
||||
user = get_user_object(get_access_token(refresh_token))
|
||||
print("The user is logged in as", user['display_name'] or user['primary_email'])
|
||||
```
|
||||
|
||||
|
||||
@ -1401,5 +1401,32 @@ export class StackClientInterface {
|
||||
}
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
async cliLogin(
|
||||
loginCode: string,
|
||||
refreshToken: string,
|
||||
session: InternalSession
|
||||
): Promise<Result<undefined, KnownErrors["SchemaError"]>> {
|
||||
const responseOrError = await this.sendClientRequestAndCatchKnownError(
|
||||
"/auth/cli/complete",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
login_code: loginCode,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
},
|
||||
session,
|
||||
[KnownErrors.SchemaError]
|
||||
);
|
||||
|
||||
if (responseOrError.status === "error") {
|
||||
return Result.error(responseOrError.error);
|
||||
}
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1175,6 +1175,17 @@ const ContactChannelAlreadyUsedForAuthBySomeoneElse = createKnownErrorConstructo
|
||||
(json) => [json.type, json.contact_channel_value] as const,
|
||||
);
|
||||
|
||||
const InvalidPollingCodeError = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"INVALID_POLLING_CODE",
|
||||
(details?: Json) => [
|
||||
400,
|
||||
"The polling code is invalid or does not exist.",
|
||||
details,
|
||||
] as const,
|
||||
(json: any) => [json] as const,
|
||||
);
|
||||
|
||||
export type KnownErrors = {
|
||||
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
|
||||
};
|
||||
@ -1271,6 +1282,7 @@ export const KnownErrors = {
|
||||
TeamPermissionNotFound,
|
||||
OAuthProviderAccessDenied,
|
||||
ContactChannelAlreadyUsedForAuthBySomeoneElse,
|
||||
InvalidPollingCodeError,
|
||||
} satisfies Record<string, KnownErrorConstructor<any, any>>;
|
||||
|
||||
|
||||
|
||||
111
packages/template/src/components-page/cli-auth-confirm.tsx
Normal file
111
packages/template/src/components-page/cli-auth-confirm.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { Typography } from "@stackframe/stack-ui";
|
||||
import { useState } from "react";
|
||||
import { stackAppInternalsSymbol, useStackApp } from "..";
|
||||
import { MessageCard } from "../components/message-cards/message-card";
|
||||
import { useTranslation } from "../lib/translations";
|
||||
|
||||
export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const app = useStackApp();
|
||||
const [authorizing, setAuthorizing] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const user = app.useUser({ or: "redirect" });
|
||||
|
||||
const handleAuthorize = async () => {
|
||||
if (authorizing) return;
|
||||
|
||||
setAuthorizing(true);
|
||||
try {
|
||||
// Get login code from URL query parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const loginCode = urlParams.get("login_code");
|
||||
|
||||
if (!loginCode) {
|
||||
throw new Error("Missing login code in URL parameters");
|
||||
}
|
||||
const refreshToken = (await user.currentSession.getTokens()).refreshToken;
|
||||
if (!refreshToken) {
|
||||
throw new Error("You must be logged in to authorize CLI access");
|
||||
}
|
||||
|
||||
// Use the internal API to send the CLI login request
|
||||
const result = await (app as any)[stackAppInternalsSymbol].sendRequest("/auth/cli/complete", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
login_code: loginCode,
|
||||
refresh_token: (await user.currentSession.getTokens()).refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Authorization failed: ${result.status} ${await result.text()}`);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setAuthorizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<MessageCard
|
||||
title={t("CLI Authorization Successful")}
|
||||
fullPage={fullPage}
|
||||
primaryButtonText={t("Close")}
|
||||
primaryAction={() => window.close()}
|
||||
>
|
||||
<Typography>
|
||||
{t("The CLI application has been authorized successfully. You can now close this window and return to the command line.")}
|
||||
</Typography>
|
||||
</MessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MessageCard
|
||||
title={t("Authorization Failed")}
|
||||
fullPage={fullPage}
|
||||
primaryButtonText={t("Try Again")}
|
||||
primaryAction={() => setError(null)}
|
||||
secondaryButtonText={t("Cancel")}
|
||||
secondaryAction={() => window.close()}
|
||||
>
|
||||
<Typography className="text-red-600">
|
||||
{t("Failed to authorize the CLI application:")}
|
||||
</Typography>
|
||||
<Typography className="text-red-600">
|
||||
{error.message}
|
||||
</Typography>
|
||||
</MessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageCard
|
||||
title={t("Authorize CLI Application")}
|
||||
fullPage={fullPage}
|
||||
primaryButtonText={authorizing ? t("Authorizing...") : t("Authorize")}
|
||||
primaryAction={handleAuthorize}
|
||||
secondaryButtonText={t("Cancel")}
|
||||
secondaryAction={() => window.close()}
|
||||
>
|
||||
<Typography>
|
||||
{t("A command line application is requesting access to your account. Click the button below to authorize it.")}
|
||||
</Typography>
|
||||
<Typography variant="destructive">
|
||||
{t("WARNING: Make sure you trust the command line application, as it will gain access to your account. If you did not initiate this request, you can close this page and ignore it. We will never send you this link via email or any other means.")}
|
||||
</Typography>
|
||||
</MessageCard>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { IframePreventer } from "../components/iframe-preventer";
|
||||
import { MessageCard } from "../components/message-cards/message-card";
|
||||
import { HandlerUrls, StackClientApp } from "../lib/stack-app";
|
||||
import { AccountSettings } from "./account-settings";
|
||||
import { CliAuthConfirmation } from "./cli-auth-confirm";
|
||||
import { EmailVerification } from "./email-verification";
|
||||
import { ErrorPage } from "./error-page";
|
||||
import { ForgotPassword } from "./forgot-password";
|
||||
@ -29,6 +30,7 @@ type Components = {
|
||||
TeamInvitation: typeof TeamInvitation,
|
||||
ErrorPage: typeof ErrorPage,
|
||||
AccountSettings: typeof AccountSettings,
|
||||
CliAuthConfirmation: typeof CliAuthConfirmation,
|
||||
};
|
||||
|
||||
type RouteProps = {
|
||||
@ -49,6 +51,7 @@ const availablePaths = {
|
||||
magicLinkCallback: 'magic-link-callback',
|
||||
teamInvitation: 'team-invitation',
|
||||
accountSettings: 'account-settings',
|
||||
cliAuthConfirm: 'cli-auth-confirm',
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
||||
@ -160,6 +163,12 @@ function renderComponent(props: {
|
||||
{...filterUndefinedINU(componentProps?.ErrorPage)}
|
||||
/>;
|
||||
}
|
||||
case availablePaths.cliAuthConfirm: {
|
||||
return <CliAuthConfirmation
|
||||
fullPage={fullPage}
|
||||
{...filterUndefinedINU(componentProps?.CliAuthConfirmation)}
|
||||
/>;
|
||||
}
|
||||
default: {
|
||||
if (Object.values(availablePaths).includes(path as any)) {
|
||||
throw new StackAssertionError(`Path alias ${path} not included in switch statement, but in availablePaths?`, { availablePaths });
|
||||
@ -323,6 +332,7 @@ export default NextStackHandler;
|
||||
export default ReactStackHandler;
|
||||
END_PLATFORM */
|
||||
|
||||
// filter undefined values in object. if object itself is undefined, return undefined
|
||||
function filterUndefinedINU<T extends {}>(value: T | undefined): FilterUndefined<T> | undefined {
|
||||
return value === undefined ? value : filterUndefined(value);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user