diff --git a/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/migration.sql b/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/migration.sql new file mode 100644 index 000000000..8b8b6c5ec --- /dev/null +++ b/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/migration.sql @@ -0,0 +1,34 @@ +CREATE TYPE "AgentAuthRegistrationType" AS ENUM ('anonymous', 'service_auth'); + +CREATE TYPE "AgentAuthRegistrationStatus" AS ENUM ('pending', 'claimed', 'expired'); + +CREATE TABLE "AgentAuthRegistration" ( + "tenancyId" UUID NOT NULL, + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "type" "AgentAuthRegistrationType" NOT NULL, + "status" "AgentAuthRegistrationStatus" NOT NULL DEFAULT 'pending', + "loginHint" TEXT, + "claimToken" TEXT NOT NULL, + "claimAttemptToken" TEXT, + "userCode" TEXT, + "claimAttemptExpiresAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3) NOT NULL, + "userId" UUID, + "refreshTokenId" UUID, + "lastPollAt" TIMESTAMP(3), + "usedAt" TIMESTAMP(3), + "claimedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentAuthRegistration_pkey" PRIMARY KEY ("tenancyId","id") +); + +CREATE UNIQUE INDEX "AgentAuthRegistration_claimToken_key" ON "AgentAuthRegistration"("claimToken"); +CREATE UNIQUE INDEX "AgentAuthRegistration_claimAttemptToken_key" ON "AgentAuthRegistration"("claimAttemptToken"); +CREATE INDEX "AgentAuthRegistration_tenancyId_expiresAt_idx" ON "AgentAuthRegistration"("tenancyId", "expiresAt"); +CREATE INDEX "AgentAuthRegistration_tenancyId_claimAttemptExpiresAt_idx" ON "AgentAuthRegistration"("tenancyId", "claimAttemptExpiresAt"); + +ALTER TABLE "AgentAuthRegistration" + ADD CONSTRAINT "AgentAuthRegistration_tenancyId_fkey" + FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/tests/creates-agent-auth-registration-table.ts b/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/tests/creates-agent-auth-registration-table.ts new file mode 100644 index 000000000..e4eae8b4b --- /dev/null +++ b/apps/backend/prisma/migrations/20260707000000_add_agent_auth_registration/tests/creates-agent-auth-registration-table.ts @@ -0,0 +1,185 @@ +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(); + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Agent Auth Migration Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + + return { tenancyId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const enums = await sql<{ type_name: string, enum_label: string }[]>` + SELECT t.typname AS type_name, e.enumlabel AS enum_label + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE t.typname IN ('AgentAuthRegistrationType', 'AgentAuthRegistrationStatus') + ORDER BY t.typname, e.enumsortorder + `; + expect(Array.from(enums)).toMatchInlineSnapshot(` + [ + { + "enum_label": "pending", + "type_name": "AgentAuthRegistrationStatus", + }, + { + "enum_label": "claimed", + "type_name": "AgentAuthRegistrationStatus", + }, + { + "enum_label": "expired", + "type_name": "AgentAuthRegistrationStatus", + }, + { + "enum_label": "anonymous", + "type_name": "AgentAuthRegistrationType", + }, + { + "enum_label": "service_auth", + "type_name": "AgentAuthRegistrationType", + }, + ] + `); + + const tables = await sql<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'AgentAuthRegistration' + `; + expect(Array.from(tables)).toMatchInlineSnapshot(` + [ + { + "table_name": "AgentAuthRegistration", + }, + ] + `); + + const claimToken = `claim-token-${randomUUID()}`; + const claimAttemptToken = `claim-attempt-${randomUUID()}`; + const userCode = "123456"; + + await sql` + INSERT INTO "AgentAuthRegistration" ( + "tenancyId", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'service_auth', + 'pending', + 'agent@example.com', + ${claimToken}, + ${claimAttemptToken}, + ${userCode}, + NOW() + INTERVAL '10 minutes', + NOW() + INTERVAL '1 day', + NOW(), + NOW() + ) + `; + + await sql` + INSERT INTO "AgentAuthRegistration" ( + "tenancyId", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "expiresAt", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'anonymous', + 'pending', + NULL, + ${`claim-token-null-${randomUUID()}`}, + NULL, + NOW() + INTERVAL '1 day', + NOW(), + NOW() + ) + `; + + const rows = await sql` + SELECT "status", "loginHint", "claimAttemptToken", "userCode" + FROM "AgentAuthRegistration" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "claimToken" = ${claimToken} + `; + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("pending"); + expect(rows[0].loginHint).toBe("agent@example.com"); + expect(rows[0].claimAttemptToken).toBe(claimAttemptToken); + expect(rows[0].userCode).toBe(userCode); + + await expect(sql` + INSERT INTO "AgentAuthRegistration" ( + "tenancyId", + "type", + "status", + "loginHint", + "claimToken", + "expiresAt", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'anonymous', + 'pending', + NULL, + ${claimToken}, + NOW() + INTERVAL '1 day', + NOW(), + NOW() + ) + `).rejects.toThrow(/AgentAuthRegistration_claimToken_key/); + + await expect(sql` + INSERT INTO "AgentAuthRegistration" ( + "tenancyId", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "expiresAt", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'service_auth', + 'pending', + NULL, + ${`claim-token-second-${randomUUID()}`}, + ${claimAttemptToken}, + NOW() + INTERVAL '1 day', + NOW(), + NOW() + ) + `).rejects.toThrow(/AgentAuthRegistration_claimAttemptToken_key/); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7d7f55b62..29cc79151 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1142,6 +1142,41 @@ model CliAuthAttempt { @@id([tenancyId, id]) } +enum AgentAuthRegistrationType { + anonymous + service_auth +} + +enum AgentAuthRegistrationStatus { + pending + claimed + expired +} + +model AgentAuthRegistration { + tenancyId String @db.Uuid + + id String @default(uuid()) @db.Uuid + type AgentAuthRegistrationType + status AgentAuthRegistrationStatus @default(pending) + loginHint String? + claimToken String @unique + claimAttemptToken String? @unique + userCode String? + claimAttemptExpiresAt DateTime? + expiresAt DateTime + userId String? @db.Uuid + refreshTokenId String? @db.Uuid + lastPollAt DateTime? + usedAt DateTime? + claimedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([tenancyId, id]) +} + model UserNotificationPreference { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid diff --git a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx index ce24648f5..1e8ecc0be 100644 --- a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx +++ b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx @@ -16,16 +16,24 @@ import { generateUuid } from "@hexclave/shared/dist/utils/uuids"; import * as yup from "yup"; -async function throwIfFeatureDisabled(tenancy: Tenancy, type: "team" | "user") { +export function isFeatureDisabled(tenancy: Tenancy, type: "team" | "user") { if (type === "team") { if (!tenancy.config.apiKeys.enabled.team) { - throw new StatusError(StatusError.BadRequest, "Team API keys are not enabled for this project."); + return true; } } else { if (!tenancy.config.apiKeys.enabled.user) { - throw new StatusError(StatusError.BadRequest, "User API keys are not enabled for this project."); + return true; } } + + return false; +} + +export async function throwIfFeatureDisabled(tenancy: Tenancy, type: "team" | "user") { + if (isFeatureDisabled(tenancy, type)) { + throw new StatusError(StatusError.BadRequest, type === "team" ? "Team API keys are not enabled for this project." : "User API keys are not enabled for this project."); + } } async function ensureUserCanManageApiKeys( diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index c81a3f104..731ba94b0 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1942,7 +1942,7 @@ async function loadAnalyticsOverview( async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { const clickhouseClient = getClickhouseAdminClientForMetrics(); - const [usersRow, teamsRow, dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([ + const [usersRow, teamsRow, dailyActiveUsersSplit, dailyActiveTeamsSplit, mau, agentAuthCounts] = await Promise.all([ clickhouseClient.query({ query: ` SELECT @@ -1997,6 +1997,31 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), loadDailyActiveTeamsSplit(tenancy, now), loadMonthlyActiveUsers(tenancy, now, includeAnonymous), + clickhouseClient.query({ + query: ` + SELECT + countIf(event_type = '$agent-auth-registration') AS total_registrations, + countIf(event_type = '$agent-auth-claim-completed') AS completed_claims, + countIf(event_type = '$agent-auth-claim-completed' AND coalesce(CAST(data.is_new_user, 'Nullable(UInt8)'), 0) = 1) AS new_user_signups, + countIf(event_type = '$agent-auth-api-key-issued') AS api_keys_issued + FROM analytics_internal.events + WHERE project_id = {projectId:String} + AND branch_id = {branchId:String} + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + }, + format: "JSONEachRow", + }).then(async (r) => { + const rows = await r.json() as [{ + total_registrations: string | number, + completed_claims: string | number, + new_user_signups: string | number, + api_keys_issued: string | number, + }]; + return rows[0]; + }), ]); const totalUsers = Number(usersRow.total_users); @@ -2020,6 +2045,12 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now daily_active_users_split: dailyActiveUsersSplit, daily_active_teams_split: dailyActiveTeamsSplit, total_users_filtered: totalUsersFiltered, + agent_auth: { + total_registrations: Number(agentAuthCounts.total_registrations), + completed_claims: Number(agentAuthCounts.completed_claims), + new_user_signups: Number(agentAuthCounts.new_user_signups), + api_keys_issued: Number(agentAuthCounts.api_keys_issued), + }, }; } diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-authorization-server/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..2459091cf --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,59 @@ +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { AGENT_AUTH_EVENTS_SUPPORTED, AGENT_AUTH_SCOPES_SUPPORTED, getAgentAuthIdentityTypesSupported, getAgentAuthProjectUrls } from "@/lib/agent-auth"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }), + }), + response: yupMixed().defined(), + async handler({ params }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new StatusError(404, "Project not found"); + } + + if (tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const urls = getAgentAuthProjectUrls(getApiUrlForRequest(fullReq), params.project_id); + const identityTypesSupported = getAgentAuthIdentityTypesSupported(tenancy.config); + + return { + statusCode: 200, + bodyType: "json", + body: { + resource: urls.projectBaseUrl, + resource_name: tenancy.project.display_name, + resource_logo_uri: tenancy.project.logo_url ?? tenancy.project.logo_full_url ?? undefined, + authorization_servers: [urls.projectBaseUrl], + scopes_supported: [...AGENT_AUTH_SCOPES_SUPPORTED], + bearer_methods_supported: ["header"], + + issuer: urls.projectBaseUrl, + token_endpoint: urls.tokenEndpointUrl, + revocation_endpoint: urls.revocationEndpointUrl, + grant_types_supported: [ + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:workos:agent-auth:grant-type:claim", + ], + agent_auth: { + skill: urls.authMdUrl, + identity_endpoint: urls.identityEndpointUrl, + claim_endpoint: urls.claimEndpointUrl, + events_endpoint: urls.eventsEndpointUrl, + identity_types_supported: identityTypesSupported, + events_supported: [...AGENT_AUTH_EVENTS_SUPPORTED], + }, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-protected-resource/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 000000000..eff35658d --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,42 @@ +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { AGENT_AUTH_SCOPES_SUPPORTED, getAgentAuthProjectUrls } from "@/lib/agent-auth"; +import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }), + }), + response: yupMixed().defined(), + async handler({ params }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new StatusError(404, "Project not found"); + } + + if (tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const urls = getAgentAuthProjectUrls(getApiUrlForRequest(fullReq), params.project_id); + + return { + statusCode: 200, + bodyType: "json", + body: { + resource: urls.projectBaseUrl, + resource_name: tenancy.project.display_name, + resource_logo_uri: tenancy.project.logo_url ?? tenancy.project.logo_full_url ?? undefined, + authorization_servers: [urls.projectBaseUrl], + scopes_supported: [...AGENT_AUTH_SCOPES_SUPPORTED], + bearer_methods_supported: ["header"], + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/api-keys/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/api-keys/route.ts new file mode 100644 index 000000000..910aa2450 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/api-keys/route.ts @@ -0,0 +1,111 @@ +import { isFeatureDisabled } from "@/app/api/latest/(api-keys)/handlers"; +import { logEvent, SystemEventTypes } from "@/lib/events"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { createProjectApiKey } from "@hexclave/shared/dist/utils/api-keys"; +import { clientOrHigherAuthTypeSchema, adaptSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { generateUuid } from "@hexclave/shared/dist/utils/uuids"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import type { Json } from "@hexclave/shared/dist/utils/json"; + +function createJsonResponse(statusCode: number, body: Json) { + return { + statusCode, + bodyType: "json" as const, + body, + }; +} + +function getDashboardUrl() { + return getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"); +} + +function getEnableUrl(projectId: string) { + return new URL(`/projects/${encodeURIComponent(projectId)}/api-keys-app`, getDashboardUrl()).toString(); +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + project: adaptSchema.defined(), + user: adaptSchema.optional(), + }).defined(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + description: yupString().defined(), + expires_at_millis: yupNumber().nullable().defined(), + is_public: yupBoolean().optional(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ auth, params, body }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + if (tenancy.config.apps.installed["api-keys"]?.enabled !== true || isFeatureDisabled(tenancy, "user")) { + return createJsonResponse(403, { + error: "api_keys_app_not_enabled", + error_description: "Agent API key issuance is not enabled for this project.", + enable_url: getEnableUrl(params.project_id), + }); + } + + const currentUser = auth.user; + if (currentUser == null || currentUser.is_anonymous === true) { + return createJsonResponse(401, { + error: "access_denied", + }); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + const apiKeyId = generateUuid(); + const isPublic = body.is_public ?? false; + const secretApiKey = createProjectApiKey({ + id: apiKeyId, + isPublic, + isCloudVersion: new URL(fullReq.url).hostname === "api.hexclave.com" || new URL(fullReq.url).hostname === "api.stack-auth.com", + type: "user", + }); + const apiKey = await prisma.projectApiKey.create({ + data: { + id: apiKeyId, + description: body.description, + secretApiKey, + isPublic, + expiresAt: body.expires_at_millis == null ? undefined : new Date(body.expires_at_millis), + createdAt: new Date(), + projectUserId: currentUser.id, + tenancyId: tenancy.id, + }, + }); + + await logEvent([SystemEventTypes.AgentAuthApiKeyIssued], { + projectId: tenancy.project.id, + userId: currentUser.id, + }, { + billingTeamId: getBillingTeamId(tenancy.project), + }); + + return createJsonResponse(200, { + id: apiKey.id, + description: apiKey.description, + is_public: apiKey.isPublic, + created_at_millis: apiKey.createdAt.getTime(), + expires_at_millis: apiKey.expiresAt == null ? null : apiKey.expiresAt.getTime(), + manually_revoked_at_millis: apiKey.manuallyRevokedAt == null ? null : apiKey.manuallyRevokedAt.getTime(), + value: apiKey.secretApiKey, + user_id: apiKey.projectUserId == null ? null : apiKey.projectUserId, + type: "user", + }); + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/complete/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/complete/route.ts new file mode 100644 index 000000000..b0ba09f66 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/complete/route.ts @@ -0,0 +1,161 @@ +import { claimAgentAuthRegistration, getAgentAuthRegistrationByClaimAttemptToken } from "@/lib/agent-auth-registration"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { createRefreshTokenObj, generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens"; +import { logEvent, SystemEventTypes } from "@/lib/events"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { recordExternalDbSyncDeletion } from "@/lib/external-db-sync"; +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { yupMixed, yupObject, yupString, adaptSchema, clientOrHigherAuthTypeSchema } from "@hexclave/shared/dist/schema-fields"; +import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import type { Json } from "@hexclave/shared/dist/utils/json"; + +function createJsonResponse(statusCode: number, body: Json) { + return { + statusCode, + bodyType: "json" as const, + body, + }; +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + user: adaptSchema.optional(), + }).defined(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + claim_attempt_token: yupString().defined(), + user_code: yupString().defined(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ auth, params, body }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const currentUser = auth.user; + if (currentUser == null) { + return createJsonResponse(401, { + error: "access_denied", + }); + } + if (currentUser.is_anonymous === true) { + return createJsonResponse(403, { + error: "access_denied", + }); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + const schema = await getPrismaSchemaForTenancy(tenancy); + const registration = await getAgentAuthRegistrationByClaimAttemptToken(prisma, schema, body.claim_attempt_token); + if (registration == null) { + return createJsonResponse(400, { + error: "invalid_claim_token", + }); + } + + if (registration.usedAt != null) { + return createJsonResponse(400, { + error: "claimed_or_in_flight", + }); + } + + if (registration.claimAttemptExpiresAt != null && registration.claimAttemptExpiresAt <= new Date()) { + return createJsonResponse(410, { + error: "claim_expired", + }); + } + + if (registration.loginHint != null) { + const signedInEmail = currentUser.primary_email; + if (signedInEmail == null || signedInEmail.toLowerCase() !== registration.loginHint.toLowerCase()) { + return createJsonResponse(403, { + error: "access_denied", + }); + } + if (currentUser.primary_email_verified !== true) { + return createJsonResponse(403, { + error: "access_denied", + }); + } + } + + const refreshed = await createRefreshTokenObj({ + tenancy, + projectUserId: currentUser.id, + }); + + const claimed = await claimAgentAuthRegistration(prisma, schema, { + claimAttemptToken: body.claim_attempt_token, + userCode: body.user_code, + userId: currentUser.id, + refreshTokenId: refreshed.id, + }); + + if (claimed == null) { + await recordExternalDbSyncDeletion(globalPrismaClient, { + tableName: "ProjectUserRefreshToken", + tenancyId: tenancy.id, + refreshTokenId: refreshed.id, + }); + await globalPrismaClient.projectUserRefreshToken.deleteMany({ + where: { + tenancyId: tenancy.id, + id: refreshed.id, + }, + }); + return createJsonResponse(400, { + error: "invalid_claim_token", + }); + } + + if (registration.refreshTokenId != null) { + await recordExternalDbSyncDeletion(globalPrismaClient, { + tableName: "ProjectUserRefreshToken", + tenancyId: tenancy.id, + refreshTokenId: registration.refreshTokenId, + }); + await globalPrismaClient.projectUserRefreshToken.deleteMany({ + where: { + tenancyId: tenancy.id, + id: registration.refreshTokenId, + }, + }); + } + + const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ + tenancy, + refreshTokenObj: refreshed, + apiUrl: getApiUrlForRequest(fullReq), + }) ?? throwErr("Failed to generate access token after agent auth claim completion", { refreshed }); + + const assertionExpires = refreshed.expiresAt ?? throwErr("Missing refresh token expiration after agent auth claim completion", { refreshed }); + + await logEvent([SystemEventTypes.AgentAuthClaimCompleted], { + projectId: tenancy.project.id, + type: registration.type, + userId: currentUser.id, + is_new_user: registration.type === "anonymous" || currentUser.signed_up_at_millis >= registration.createdAt.getTime(), + }, { + billingTeamId: getBillingTeamId(tenancy.project), + }); + + return createJsonResponse(200, { + success: true, + access_token: accessToken, + refresh_token: refreshed.refreshToken, + user_id: currentUser.id, + identity_assertion: refreshed.refreshToken, + assertion_expires: assertionExpires.toISOString(), + }); + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/route.ts new file mode 100644 index 000000000..5ffae559f --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/claim/route.ts @@ -0,0 +1,119 @@ +import { getAgentAuthRegistrationByClaimToken, updateAgentAuthClaimAttempt } from "@/lib/agent-auth-registration"; +import { getAgentAuthClaimPageUrl } from "@/lib/agent-auth"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; +import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import type { Json } from "@hexclave/shared/dist/utils/json"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { randomInt } from "crypto"; + +const claimAttemptWindowMillis = 1000 * 60 * 10; +const claimWindowMillis = 1000 * 60 * 60 * 24; +const claimPollingIntervalSeconds = 5; + +function createJsonResponse(statusCode: number, body: Json) { + return { + statusCode, + bodyType: "json" as const, + body, + }; +} + +function sixDigitCode() { + return randomInt(100000, 1000000).toString(); +} + +function getDashboardUrl() { + return getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"); +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + claim_token: yupString().defined(), + email: yupString().optional(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ params, body }) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + const schema = await getPrismaSchemaForTenancy(tenancy); + const registration = await getAgentAuthRegistrationByClaimToken(prisma, schema, body.claim_token); + if (registration == null) { + return createJsonResponse(400, { + error: "invalid_claim_token", + }); + } + + if (registration.usedAt != null) { + return createJsonResponse(400, { + error: "claimed_or_in_flight", + }); + } + + if (registration.expiresAt <= new Date()) { + return createJsonResponse(410, { + error: "claim_expired", + }); + } + + if (registration.claimAttemptToken != null && registration.claimAttemptExpiresAt != null && registration.claimAttemptExpiresAt > new Date()) { + return createJsonResponse(400, { + error: "claimed_or_in_flight", + }); + } + + const claimAttemptToken = generateSecureRandomString(); + const userCode = sixDigitCode(); + const claimAttemptExpiresAt = new Date(Date.now() + claimAttemptWindowMillis); + const updated = await updateAgentAuthClaimAttempt(prisma, schema, { + claimToken: body.claim_token, + loginHint: body.email ?? registration.loginHint, + claimAttemptToken, + userCode, + claimAttemptExpiresAt, + }); + if (updated == null) { + return createJsonResponse(400, { + error: "claim_expired", + }); + } + + const verificationUri = getAgentAuthClaimPageUrl(getDashboardUrl(), { + claimAttemptToken, + projectId: params.project_id, + }); + + return createJsonResponse(200, { + claim: { + claim_token: body.claim_token, + claim_attempt_token: claimAttemptToken, + user_code: userCode, + verification_uri: verificationUri, + verification_uri_complete: `${verificationUri}&user_code=${encodeURIComponent(userCode)}`, + interval: claimPollingIntervalSeconds, + expires_in: Math.floor(claimAttemptWindowMillis / 1000), + claim_attempt_expires_in: Math.floor(claimAttemptWindowMillis / 1000), + claim_token_expires_in: Math.floor(claimWindowMillis / 1000), + }, + registration: { + id: updated.id, + status: updated.status, + type: updated.type, + }, + }); + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/route.ts new file mode 100644 index 000000000..9802cb318 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/identity/route.ts @@ -0,0 +1,202 @@ +import { createAgentAuthRegistration } from "@/lib/agent-auth-registration"; +import { AGENT_AUTH_SCOPES_SUPPORTED, getAgentAuthClaimPageUrl } from "@/lib/agent-auth"; +import { logEvent, SystemEventTypes } from "@/lib/events"; +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { ACCESS_TOKEN_EXPIRATION_SECONDS, createRefreshTokenObj, generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy } from "@/prisma-client"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; +import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import type { Json } from "@hexclave/shared/dist/utils/json"; +import { clientOrHigherAuthTypeSchema, adaptSchema, yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { randomInt } from "crypto"; + +const claimWindowMillis = 1000 * 60 * 60 * 24; +const claimAttemptWindowMillis = 1000 * 60 * 10; +const claimPollingIntervalSeconds = 5; +const preClaimScopes = [...AGENT_AUTH_SCOPES_SUPPORTED]; +const postClaimScopes = [...AGENT_AUTH_SCOPES_SUPPORTED]; + +function createJsonResponse(statusCode: number, body: Json) { + return { + statusCode, + bodyType: "json" as const, + body, + }; +} + +function sixDigitCode() { + return randomInt(100000, 1000000).toString(); +} + +function toIsoString(value: Date | null | undefined) { + return value == null ? null : value.toISOString(); +} + +function getDashboardUrl() { + return getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"); +} + +function getProjectScopedUrls(projectId: string, claimAttemptToken: string) { + const dashboardUrl = getDashboardUrl(); + const verificationUri = getAgentAuthClaimPageUrl(dashboardUrl, { claimAttemptToken, projectId }); + return { verificationUri }; +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + project: adaptSchema.optional(), + user: adaptSchema.optional(), + }).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + type: yupString().oneOf(["anonymous", "service_auth"]).defined(), + login_hint: yupString().optional(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ params, body }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + + if (body.type === "anonymous") { + if (tenancy.config.agentAuth.identityTypes.anonymous !== true) { + return createJsonResponse(400, { + error: "anonymous_not_enabled", + }); + } + + const anonymousUser = await usersCrudHandlers.adminCreate({ + tenancy, + data: { + is_anonymous: true, + }, + allowedErrorTypes: [], + }); + + const refreshTokenObj = await createRefreshTokenObj({ + tenancy, + projectUserId: anonymousUser.id, + }); + const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ + tenancy, + refreshTokenObj, + apiUrl: getApiUrlForRequest(fullReq), + }) ?? throwErr("Failed to generate access token for new anonymous agent auth registration", { refreshTokenObj }); + + // WorkOS `identity_assertion` maps to Stack Auth's refresh token here: + // it is the long-lived credential that the token endpoint exchanges for + // fresh access tokens after the claim ceremony completes. + const claimToken = generateSecureRandomString(); + const registration = await createAgentAuthRegistration(prisma, schema, { + tenancyId: tenancy.id, + type: "anonymous", + loginHint: null, + claimToken, + expiresAt: new Date(Date.now() + claimWindowMillis), + userId: anonymousUser.id, + refreshTokenId: refreshTokenObj.id, + }); + + await logEvent([SystemEventTypes.AgentAuthRegistration], { + projectId: tenancy.project.id, + type: "anonymous", + }, { + billingTeamId: getBillingTeamId(tenancy.project), + }); + + return createJsonResponse(200, { + registration: { + id: registration.id, + type: registration.type, + status: registration.status, + expires_at: registration.expiresAt.toISOString(), + }, + identity_assertion: refreshTokenObj.refreshToken, + assertion_expires: toIsoString(refreshTokenObj.expiresAt), + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_EXPIRATION_SECONDS, + claim_token: claimToken, + claim_token_expires_at: registration.expiresAt.toISOString(), + pre_claim_scopes: preClaimScopes, + post_claim_scopes: postClaimScopes, + }); + } + + if (tenancy.config.agentAuth.identityTypes.serviceAuth !== true) { + return createJsonResponse(400, { + error: "service_auth_not_enabled", + }); + } + + const loginHint = body.login_hint ?? null; + if (loginHint == null) { + return createJsonResponse(400, { + error: "invalid_request", + error_description: "login_hint is required for service_auth registrations", + }); + } + + const claimToken = generateSecureRandomString(); + const claimAttemptToken = generateSecureRandomString(); + const userCode = sixDigitCode(); + const claimAttemptExpiresAt = new Date(Date.now() + claimAttemptWindowMillis); + + const registration = await createAgentAuthRegistration(prisma, schema, { + tenancyId: tenancy.id, + type: "service_auth", + loginHint, + claimToken, + claimAttemptToken, + userCode, + claimAttemptExpiresAt, + expiresAt: new Date(Date.now() + claimWindowMillis), + }); + + const { verificationUri } = getProjectScopedUrls(params.project_id, claimAttemptToken); + + await logEvent([SystemEventTypes.AgentAuthRegistration], { + projectId: tenancy.project.id, + type: "service_auth", + }, { + billingTeamId: getBillingTeamId(tenancy.project), + }); + + return createJsonResponse(200, { + registration: { + id: registration.id, + type: registration.type, + status: registration.status, + expires_at: registration.expiresAt.toISOString(), + }, + claim_token: claimToken, + claim_token_expires_at: registration.expiresAt.toISOString(), + claim: { + user_code: userCode, + verification_uri: verificationUri, + verification_uri_complete: `${verificationUri}&user_code=${encodeURIComponent(userCode)}`, + interval: claimPollingIntervalSeconds, + expires_in: Math.floor(claimAttemptWindowMillis / 1000), + }, + verification_uri: verificationUri, + verification_uri_complete: `${verificationUri}&user_code=${encodeURIComponent(userCode)}`, + post_claim_scopes: postClaimScopes, + }); + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/revoke/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/revoke/route.ts new file mode 100644 index 000000000..f2a360d73 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/revoke/route.ts @@ -0,0 +1,76 @@ +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { decodeAccessToken } from "@/lib/tokens"; +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { recordExternalDbSyncDeletion } from "@/lib/external-db-sync"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + token: yupString().defined(), + token_type_hint: yupString().optional(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ params, body }) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + return { statusCode: 200, bodyType: "success" as const }; + } + + if (body.token_type_hint === "refresh_token") { + const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findFirst({ + where: { + tenancyId: tenancy.id, + refreshToken: body.token, + }, + }); + + if (refreshTokenObj != null) { + await recordExternalDbSyncDeletion(globalPrismaClient, { + tableName: "ProjectUserRefreshToken", + tenancyId: tenancy.id, + refreshTokenId: refreshTokenObj.id, + }); + await globalPrismaClient.projectUserRefreshToken.deleteMany({ + where: { + tenancyId: tenancy.id, + id: refreshTokenObj.id, + }, + }); + } + + return { statusCode: 200, bodyType: "success" as const }; + } + + const decoded = await decodeAccessToken(body.token, { allowAnonymous: true, allowRestricted: true }); + if (decoded.status === "error") { + return { statusCode: 200, bodyType: "success" as const }; + } + + const refreshTokenId = decoded.data.refreshTokenId; + if (refreshTokenId == null) { + return { statusCode: 200, bodyType: "success" as const }; + } + + await recordExternalDbSyncDeletion(globalPrismaClient, { + tableName: "ProjectUserRefreshToken", + tenancyId: tenancy.id, + refreshTokenId, + }); + await globalPrismaClient.projectUserRefreshToken.deleteMany({ + where: { + tenancyId: tenancy.id, + id: refreshTokenId, + }, + }); + + return { statusCode: 200, bodyType: "success" as const }; + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/agent/token/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/agent/token/route.ts new file mode 100644 index 000000000..d945ef704 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/agent/token/route.ts @@ -0,0 +1,183 @@ +import { AGENT_AUTH_SCOPES_SUPPORTED } from "@/lib/agent-auth"; +import { markAgentAuthClaimPolled, getAgentAuthRegistrationByClaimToken } from "@/lib/agent-auth-registration"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { ACCESS_TOKEN_EXPIRATION_SECONDS, generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens"; +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { getPrismaSchemaForTenancy, globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import type { Json } from "@hexclave/shared/dist/utils/json"; + +const claimPollingIntervalSeconds = 5; + +function createJsonResponse(statusCode: number, body: Json) { + return { + statusCode, + bodyType: "json" as const, + body, + }; +} + +function getTokenResponse(accessToken: string, identityAssertion: string, assertionExpires: Date | null) { + const assertionExpiresAt = assertionExpires ?? throwErr("Missing assertion expiration for agent auth token exchange"); + return createJsonResponse(200, { + token_type: "Bearer", + expires_in: ACCESS_TOKEN_EXPIRATION_SECONDS, + scope: AGENT_AUTH_SCOPES_SUPPORTED.join(" "), + access_token: accessToken, + identity_assertion: identityAssertion, + assertion_expires: assertionExpiresAt.toISOString(), + }); +} + +export const POST = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }).defined(), + body: yupObject({ + grant_type: yupString().defined(), + claim_token: yupString().optional(), + assertion: yupString().optional(), + }).defined(), + }), + response: yupMixed().defined(), + async handler({ params, body }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null || tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const schema = await getPrismaSchemaForTenancy(tenancy); + + if (body.grant_type === "urn:workos:agent-auth:grant-type:claim") { + const claimToken = body.claim_token; + if (claimToken == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + const registration = await getAgentAuthRegistrationByClaimToken(globalPrismaClient, schema, claimToken); + if (registration == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + if (registration.expiresAt <= new Date()) { + return createJsonResponse(400, { + error: "expired_token", + }); + } + + let completedRegistration = registration; + if (registration.usedAt == null) { + if (registration.claimAttemptExpiresAt != null && registration.claimAttemptExpiresAt <= new Date()) { + return createJsonResponse(400, { + error: "expired_token", + }); + } + + if (registration.lastPollAt != null && Date.now() - registration.lastPollAt.getTime() < claimPollingIntervalSeconds * 1000) { + return createJsonResponse(400, { + error: "slow_down", + }); + } + + const updated = await markAgentAuthClaimPolled(globalPrismaClient, schema, { + claimToken, + }); + if (updated == null) { + return createJsonResponse(400, { + error: "expired_token", + }); + } + + if (updated.usedAt == null || updated.refreshTokenId == null) { + return createJsonResponse(400, { + error: "authorization_pending", + interval: claimPollingIntervalSeconds, + }); + } + + completedRegistration = updated; + } + + if (completedRegistration.refreshTokenId == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findFirst({ + where: { + tenancyId: tenancy.id, + id: completedRegistration.refreshTokenId, + }, + }); + + if (refreshTokenObj == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ + tenancy, + refreshTokenObj, + apiUrl: getApiUrlForRequest(fullReq), + }); + + if (accessToken == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + return getTokenResponse(accessToken, refreshTokenObj.refreshToken, refreshTokenObj.expiresAt); + } + + if (body.grant_type === "urn:ietf:params:oauth:grant-type:jwt-bearer") { + const assertion = body.assertion; + if (assertion == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findFirst({ + where: { + tenancyId: tenancy.id, + refreshToken: assertion, + }, + }); + + if (refreshTokenObj == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ + tenancy, + refreshTokenObj, + apiUrl: getApiUrlForRequest(fullReq), + }); + if (accessToken == null) { + return createJsonResponse(400, { + error: "invalid_grant", + }); + } + + return getTokenResponse(accessToken, refreshTokenObj.refreshToken, refreshTokenObj.expiresAt); + } + + return createJsonResponse(400, { + error: "unsupported_grant_type", + }); + }, +}); diff --git a/apps/backend/src/app/api/latest/projects/[project_id]/auth.md/route.ts b/apps/backend/src/app/api/latest/projects/[project_id]/auth.md/route.ts new file mode 100644 index 000000000..851ab4510 --- /dev/null +++ b/apps/backend/src/app/api/latest/projects/[project_id]/auth.md/route.ts @@ -0,0 +1,67 @@ +import { getApiUrlForRequest } from "@/lib/request-api-url"; +import { AGENT_AUTH_EVENTS_SUPPORTED, AGENT_AUTH_SCOPES_SUPPORTED, getAgentAuthIdentityTypesSupported, getAgentAuthProjectUrls, renderAgentAuthManifest } from "@/lib/agent-auth"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { ACCESS_TOKEN_EXPIRATION_SECONDS } from "@/lib/tokens"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import type { SmartResponse } from "@/route-handlers/smart-response"; +import { yupMixed, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +function getDashboardUrl() { + return getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"); +} + +export const GET = createSmartRouteHandler({ + request: yupObject({ + auth: yupObject({}).nullable().optional(), + params: yupObject({ + project_id: yupString().defined(), + }), + }), + response: yupMixed().defined(), + async handler({ params }, fullReq) { + const tenancy = await getSoleTenancyFromProjectBranch(params.project_id, DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new StatusError(404, "Project not found"); + } + + if (tenancy.config.apps.installed["agent-auth"]?.enabled !== true) { + throw new StatusError(404, "Project not found"); + } + + const urls = getAgentAuthProjectUrls(getApiUrlForRequest(fullReq), params.project_id); + const claimPageUrl = new URL(`/projects/${encodeURIComponent(params.project_id)}/agent-auth-app/claim`, getDashboardUrl()).toString(); + const apiKeysEnableUrl = new URL(`/projects/${encodeURIComponent(params.project_id)}/api-keys-app`, getDashboardUrl()).toString(); + const manifest = renderAgentAuthManifest({ + projectName: tenancy.project.display_name, + resourceUrl: urls.resourceMetadataUrl, + authorizationServerUrl: urls.authorizationServerMetadataUrl, + authMdUrl: urls.authMdUrl, + identityEndpointUrl: urls.identityEndpointUrl, + claimEndpointUrl: urls.claimEndpointUrl, + claimPageUrl, + eventsEndpointUrl: urls.eventsEndpointUrl, + tokenEndpointUrl: urls.tokenEndpointUrl, + revocationEndpointUrl: urls.revocationEndpointUrl, + apiKeysEndpointUrl: urls.apiKeysEndpointUrl, + apiKeysEnableUrl, + accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRATION_SECONDS, + claimAttemptExpiresInSeconds: 10 * 60, + scopesSupported: AGENT_AUTH_SCOPES_SUPPORTED, + identityTypesSupported: getAgentAuthIdentityTypesSupported(tenancy.config), + eventsSupported: AGENT_AUTH_EVENTS_SUPPORTED, + resourceLogoUrl: tenancy.project.logo_url ?? tenancy.project.logo_full_url ?? null, + }); + + return { + statusCode: 200, + bodyType: "response", + body: new Response(manifest, { + headers: { + "content-type": "text/markdown; charset=utf-8", + }, + }), + }; + }, +}); diff --git a/apps/backend/src/lib/agent-auth-registration.ts b/apps/backend/src/lib/agent-auth-registration.ts new file mode 100644 index 000000000..432d51d6e --- /dev/null +++ b/apps/backend/src/lib/agent-auth-registration.ts @@ -0,0 +1,256 @@ +import { Prisma } from "@/generated/prisma/client"; +import { sqlQuoteIdent } from "@/prisma-client"; +import type { PrismaClientWithReplica } from "@/prisma-client"; + +export type AgentAuthRegistrationType = "anonymous" | "service_auth"; +export type AgentAuthRegistrationStatus = "pending" | "claimed" | "expired"; + +export type AgentAuthRegistrationRow = { + tenancyId: string, + id: string, + type: AgentAuthRegistrationType, + status: AgentAuthRegistrationStatus, + loginHint: string | null, + claimToken: string, + claimAttemptToken: string | null, + userCode: string | null, + claimAttemptExpiresAt: Date | null, + expiresAt: Date, + userId: string | null, + refreshTokenId: string | null, + lastPollAt: Date | null, + usedAt: Date | null, + claimedAt: Date | null, + createdAt: Date, + updatedAt: Date, +}; + +function selectColumns(schema: string) { + return Prisma.sql` + SELECT + "tenancyId", + "id", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "createdAt", + "updatedAt" + FROM ${sqlQuoteIdent(schema)}."AgentAuthRegistration" + `; +} + +export async function getAgentAuthRegistrationByClaimToken(prisma: PrismaClientWithReplica, schema: string, claimToken: string): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + ${selectColumns(schema)} + WHERE "claimToken" = ${claimToken} + LIMIT 1 + `); + return rows[0] ?? null; +} + +export async function getAgentAuthRegistrationByClaimAttemptToken(prisma: PrismaClientWithReplica, schema: string, claimAttemptToken: string): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + ${selectColumns(schema)} + WHERE "claimAttemptToken" = ${claimAttemptToken} + LIMIT 1 + `); + return rows[0] ?? null; +} + +export async function createAgentAuthRegistration(prisma: PrismaClientWithReplica, schema: string, options: { + tenancyId: string, + type: AgentAuthRegistrationType, + loginHint: string | null, + claimToken: string, + claimAttemptToken?: string | null, + userCode?: string | null, + claimAttemptExpiresAt?: Date | null, + expiresAt: Date, + userId?: string | null, + refreshTokenId?: string | null, + lastPollAt?: Date | null, +}): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + INSERT INTO ${sqlQuoteIdent(schema)}."AgentAuthRegistration" ( + "tenancyId", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "updatedAt" + ) + VALUES ( + ${options.tenancyId}::UUID, + ${options.type}, + 'pending', + ${options.loginHint}, + ${options.claimToken}, + ${options.claimAttemptToken ?? null}, + ${options.userCode ?? null}, + ${options.claimAttemptExpiresAt ?? null}, + ${options.expiresAt}, + ${options.userId ?? null}, + ${options.refreshTokenId ?? null}, + ${options.lastPollAt ?? null}, + NULL, + NULL, + NOW() + ) + RETURNING + "tenancyId", + "id", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "createdAt", + "updatedAt" + `); + if (rows.length === 0) { + throw new Error("Agent auth registration insert failed"); + } + return rows[0]; +} + +export async function updateAgentAuthClaimAttempt(prisma: PrismaClientWithReplica, schema: string, options: { + claimToken: string, + loginHint: string | null, + claimAttemptToken: string, + userCode: string, + claimAttemptExpiresAt: Date, +}): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + UPDATE ${sqlQuoteIdent(schema)}."AgentAuthRegistration" + SET + "claimAttemptToken" = ${options.claimAttemptToken}, + "userCode" = ${options.userCode}, + "loginHint" = COALESCE(${options.loginHint}, "loginHint"), + "claimAttemptExpiresAt" = ${options.claimAttemptExpiresAt}, + "updatedAt" = NOW() + WHERE "claimToken" = ${options.claimToken} + AND "usedAt" IS NULL + AND "expiresAt" > NOW() + RETURNING + "tenancyId", + "id", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "createdAt", + "updatedAt" + `); + return rows[0] ?? null; +} + +export async function markAgentAuthClaimPolled(prisma: PrismaClientWithReplica, schema: string, options: { + claimToken: string, +}): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + UPDATE ${sqlQuoteIdent(schema)}."AgentAuthRegistration" + SET + "lastPollAt" = NOW(), + "updatedAt" = NOW() + WHERE "claimToken" = ${options.claimToken} + AND "usedAt" IS NULL + AND "expiresAt" > NOW() + RETURNING + "tenancyId", + "id", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "createdAt", + "updatedAt" + `); + return rows[0] ?? null; +} + +export async function claimAgentAuthRegistration(prisma: PrismaClientWithReplica, schema: string, options: { + claimAttemptToken: string, + userCode: string, + userId: string, + refreshTokenId: string, +}): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + UPDATE ${sqlQuoteIdent(schema)}."AgentAuthRegistration" + SET + "status" = 'claimed', + "userId" = ${options.userId}::UUID, + "refreshTokenId" = ${options.refreshTokenId}::UUID, + "usedAt" = NOW(), + "claimedAt" = NOW(), + "updatedAt" = NOW() + WHERE "claimAttemptToken" = ${options.claimAttemptToken} + AND "userCode" = ${options.userCode} + AND "usedAt" IS NULL + AND "expiresAt" > NOW() + AND "claimAttemptExpiresAt" > NOW() + RETURNING + "tenancyId", + "id", + "type", + "status", + "loginHint", + "claimToken", + "claimAttemptToken", + "userCode", + "claimAttemptExpiresAt", + "expiresAt", + "userId", + "refreshTokenId", + "lastPollAt", + "usedAt", + "claimedAt", + "createdAt", + "updatedAt" + `); + return rows[0] ?? null; +} diff --git a/apps/backend/src/lib/agent-auth.ts b/apps/backend/src/lib/agent-auth.ts new file mode 100644 index 000000000..a5d52c446 --- /dev/null +++ b/apps/backend/src/lib/agent-auth.ts @@ -0,0 +1,271 @@ +export type AgentAuthIdentityType = "anonymous" | "service_auth"; + +export const AGENT_AUTH_SCOPES_SUPPORTED = ["agent.auth"] as const; + +export const AGENT_AUTH_EVENTS_SUPPORTED = [ + "https://schemas.workos.com/events/agent/auth/identity/assertion/revoked", +] as const; + +export function getAgentAuthIdentityTypesSupported(config: { + agentAuth: { + identityTypes: { + serviceAuth: boolean, + anonymous: boolean, + }, + }, +}): AgentAuthIdentityType[] { + const supported: AgentAuthIdentityType[] = []; + if (config.agentAuth.identityTypes.anonymous) { + supported.push("anonymous"); + } + if (config.agentAuth.identityTypes.serviceAuth) { + supported.push("service_auth"); + } + return supported; +} + +export function getAgentAuthProjectBaseUrl(apiUrl: string, projectId: string) { + return new URL(`/api/v1/projects/${encodeURIComponent(projectId)}`, apiUrl).toString().replace(/\/$/, ""); +} + +export function getAgentAuthProjectUrls(apiUrl: string, projectId: string) { + const projectBaseUrl = getAgentAuthProjectBaseUrl(apiUrl, projectId); + + return { + projectBaseUrl, + resourceMetadataUrl: `${projectBaseUrl}/.well-known/oauth-protected-resource`, + authorizationServerMetadataUrl: `${projectBaseUrl}/.well-known/oauth-authorization-server`, + authMdUrl: `${projectBaseUrl}/auth.md`, + identityEndpointUrl: `${projectBaseUrl}/agent/identity`, + claimEndpointUrl: `${projectBaseUrl}/agent/identity/claim`, + eventsEndpointUrl: `${projectBaseUrl}/agent/event/notify`, + tokenEndpointUrl: `${projectBaseUrl}/agent/token`, + revocationEndpointUrl: `${projectBaseUrl}/agent/revoke`, + apiKeysEndpointUrl: `${projectBaseUrl}/agent/api-keys`, + }; +} + +export function getAgentAuthClaimPageUrl(dashboardUrl: string, options: { claimAttemptToken: string, projectId: string }) { + return new URL(`/projects/${encodeURIComponent(options.projectId)}/agent-auth-app/claim?claim_attempt_token=${encodeURIComponent(options.claimAttemptToken)}`, dashboardUrl).toString(); +} + +export function renderAgentAuthManifest(options: { + projectName: string, + resourceUrl: string, + authorizationServerUrl: string, + authMdUrl: string, + identityEndpointUrl: string, + claimEndpointUrl: string, + claimPageUrl: string, + eventsEndpointUrl: string, + tokenEndpointUrl: string, + revocationEndpointUrl: string, + apiKeysEndpointUrl: string, + apiKeysEnableUrl: string, + accessTokenExpiresInSeconds: number, + claimAttemptExpiresInSeconds: number, + scopesSupported: readonly string[], + identityTypesSupported: readonly AgentAuthIdentityType[], + eventsSupported: readonly string[], + resourceLogoUrl?: string | null, +}) { + const identityTypesSupported = options.identityTypesSupported.length === 0 + ? ['- none'] + : options.identityTypesSupported.map((identityType) => `- ${identityType}`); + + const supportedScopes = options.scopesSupported.map((scope) => `- ${scope}`); + const supportedEvents = options.eventsSupported.map((event) => `- ${event}`); + + return [ + `# AUTH.md for ${options.projectName}`, + '', + 'This project supports **agentic registration** for the project-scoped agent-auth surface.', + '', + '## 1. Discover', + '', + 'Fetch the project-scoped discovery documents first:', + '', + `- Protected resource metadata: \`${options.resourceUrl}\``, + `- Authorization server metadata: \`${options.authorizationServerUrl}\``, + '', + `Then read the \`skill\` document at \`${options.authMdUrl}\` and follow the URLs it returns.`, + '', + '## 2. Choose a registration method', + '', + 'Supported identity types:', + '', + ...identityTypesSupported, + '', + 'Use `anonymous` when the agent can complete the flow later through a human claim.', + 'Use `service_auth` when the agent already knows the email address that will approve the claim.', + '', + '## 3. Register', + '', + '### Anonymous registration', + '', + `POST \`${options.identityEndpointUrl}\``, + '', + 'Request:', + '', + '```json', + '{ "type": "anonymous" }', + '```', + '', + 'Response:', + '', + '```json', + '{', + ' "registration": { "id": "reg_...", "type": "anonymous", "status": "pending", "expires_at": "2026-07-07T12:34:56.000Z" },', + ' "identity_assertion": "refresh_token_like_value",', + ' "assertion_expires": "2026-07-08T12:34:56.000Z",', + ' "access_token": "stack_access_token",', + ' "token_type": "Bearer",', + ` "expires_in": ${options.accessTokenExpiresInSeconds},`, + ' "claim_token": "claim_token_value",', + ' "claim_token_expires_at": "2026-07-08T12:34:56.000Z",', + ' "pre_claim_scopes": ["agent.auth"],', + ' "post_claim_scopes": ["agent.auth"]', + '}', + '```', + '', + '### Service auth registration', + '', + `POST \`${options.identityEndpointUrl}\``, + '', + 'Request:', + '', + '```json', + '{ "type": "service_auth", "login_hint": "agent@example.com" }', + '```', + '', + 'Response:', + '', + '```json', + '{', + ' "registration": { "id": "reg_...", "type": "service_auth", "status": "pending", "expires_at": "2026-07-07T12:34:56.000Z" },', + ' "claim_token": "claim_token_value",', + ' "claim_token_expires_at": "2026-07-08T12:34:56.000Z",', + ' "claim": {', + ' "user_code": "123456",', + ` "verification_uri": "${options.claimPageUrl}?claim_attempt_token=...",`, + ` "verification_uri_complete": "${options.claimPageUrl}?claim_attempt_token=...&user_code=123456",`, + ' "interval": 5,', + ` "expires_in": ${options.claimAttemptExpiresInSeconds}`, + ' },', + ` "verification_uri": "${options.claimPageUrl}?claim_attempt_token=...",`, + ` "verification_uri_complete": "${options.claimPageUrl}?claim_attempt_token=...&user_code=123456",`, + ' "post_claim_scopes": ["agent.auth"]', + '}', + '```', + '', + 'If the project disables a requested identity type, the server returns:', + '', + '- `service_auth_not_enabled`', + '- `anonymous_not_enabled`', + '', + '## 4. Complete the claim ceremony', + '', + 'When a human opens the verification URI, they sign in normally and enter the `user_code`.', + 'The dashboard claim page submits `claim_attempt_token` + `user_code` to:', + '', + `POST \`${options.claimEndpointUrl}/complete\``, + '', + 'That endpoint returns the signed-in browser session tokens and finalizes the registration.', + '', + 'Claim errors returned by the project:', + '', + '- `invalid_claim_token`', + '- `claimed_or_in_flight`', + '- `claim_expired`', + '- `access_denied`', + '', + '## 5. Exchange', + 'After the claim is complete, obtain a fresh access token with the JWT-bearer grant:', + '', + `POST \`${options.tokenEndpointUrl}\``, + '', + '```json', + '{', + ' "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",', + ' "assertion": "identity_assertion_refresh_token"', + '}', + '```', + '', + 'Token responses always include:', + '', + '```json', + '{', + ' "token_type": "Bearer",', + ` "expires_in": ${options.accessTokenExpiresInSeconds},`, + ' "scope": "agent.auth",', + ' "access_token": "...",', + ' "identity_assertion": "...",', + ' "assertion_expires": "2026-07-08T12:34:56.000Z"', + '}', + '```', + '', + 'The claim grant also polls the same token endpoint:', + '', + '```json', + '{', + ' "grant_type": "urn:workos:agent-auth:grant-type:claim",', + ' "claim_token": "claim_token_value"', + '}', + '```', + '', + 'Poll responses may return:', + '', + '- `authorization_pending`', + '- `slow_down`', + '- `expired_token`', + '- `invalid_grant`', + '', + '## 6. Use', + '', + 'Send the minted access token in the HTTP `Authorization` header:', + '', + '```http', + 'Authorization: Bearer ', + '```', + '', + '## 7. Issue a project API key', + '', + 'When the agent needs a longer-lived API key, call:', + '', + `POST \`${options.apiKeysEndpointUrl}\``, + '', + 'If the API Keys app or user API keys are disabled, the server returns:', + '', + '```json', + '{', + ' "error": "api_keys_app_not_enabled",', + ' "error_description": "Agent API key issuance is not enabled for this project.",', + ` "enable_url": "${options.apiKeysEnableUrl}"`, + '}', + '```', + '', + 'If enabled, the endpoint reuses the normal user API key creation path and returns the same shape as the dashboard API Keys UI.', + '', + '## 8. Revoke', + '', + `POST \`${options.revocationEndpointUrl}\``, + '', + 'Revoke the access token using RFC 7009 form data:', + '', + '```x-www-form-urlencoded', + 'token=&token_type_hint=access_token', + '```', + '', + 'Revocation is idempotent. Stack Auth access tokens are stateless, so revoking one also invalidates the matching identity assertion. Re-register if you need a fresh assertion.', + '', + '## Supported scopes', + '', + ...supportedScopes, + '', + '## Supported events', + '', + ...supportedEvents, + '', + ...(options.resourceLogoUrl == null ? [] : ['## Resource logo', '', `- ${options.resourceLogoUrl}`, '']), + ].join("\n"); +} diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 6c92b2336..d06e0223d 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -169,6 +169,35 @@ const SignUpRuleTriggerEventType = { inherits: [], } as const satisfies SystemEventTypeBase; +const AgentAuthRegistrationEventType = { + id: "$agent-auth-registration", + dataSchema: yupObject({ + projectId: yupString().defined(), + type: yupString().oneOf(["anonymous", "service_auth"]).defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + +const AgentAuthClaimCompletedEventType = { + id: "$agent-auth-claim-completed", + dataSchema: yupObject({ + projectId: yupString().defined(), + type: yupString().oneOf(["anonymous", "service_auth"]).defined(), + userId: yupString().uuid().defined(), + is_new_user: yupBoolean().defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + +const AgentAuthApiKeyIssuedEventType = { + id: "$agent-auth-api-key-issued", + dataSchema: yupObject({ + projectId: yupString().defined(), + userId: yupString().uuid().defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + export const SystemEventTypes = stripEventTypeSuffixFromKeys({ ProjectEventType, ProjectActivityEventType, @@ -178,6 +207,9 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({ ApiRequestEventType, LegacyApiEventType, SignUpRuleTriggerEventType, + AgentAuthRegistrationEventType, + AgentAuthClaimCompletedEventType, + AgentAuthApiKeyIssuedEventType, } as const); const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType])); @@ -318,7 +350,7 @@ export async function logEvent( }); // Log specific events to ClickHouse - const clickhouseEventTypes = ['$token-refresh', '$sign-up-rule-trigger']; + const clickhouseEventTypes = ['$token-refresh', '$sign-up-rule-trigger', '$agent-auth-registration', '$agent-auth-claim-completed', '$agent-auth-api-key-issued']; const matchingEventType = eventTypesArray.find(e => clickhouseEventTypes.includes(e.id)); if (matchingEventType) { let clickhouseEventData: Record; @@ -368,6 +400,29 @@ export async function logEvent( auth_method: authMethod, oauth_provider: oauthProvider, }; + } else if (matchingEventType.id === "$agent-auth-registration") { + const type = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.type === "string" + ? dataRecord.type + : throwErr(new HexclaveAssertionError("type is required for $agent-auth-registration ClickHouse event", { dataRecord })); + clickhouseEventData = { + type, + }; + } else if (matchingEventType.id === "$agent-auth-claim-completed") { + const type = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.type === "string" + ? dataRecord.type + : throwErr(new HexclaveAssertionError("type is required for $agent-auth-claim-completed ClickHouse event", { dataRecord })); + const isNewUser = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.is_new_user === "boolean" + ? dataRecord.is_new_user + : throwErr(new HexclaveAssertionError("is_new_user is required for $agent-auth-claim-completed ClickHouse event", { dataRecord })); + clickhouseEventData = { + type, + is_new_user: isNewUser, + }; + } else if (matchingEventType.id === "$agent-auth-api-key-issued") { + clickhouseEventData = {}; } else { throw new HexclaveAssertionError(`Unhandled ClickHouse event type: ${matchingEventType.id}`, { matchingEventType }); } diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 01fa4f5ad..81c9ce339 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -20,6 +20,33 @@ import { CLOUD_HOST_PAIRS } from './request-api-url'; import { Tenancy } from './tenancies'; export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/); +export const ACCESS_TOKEN_EXPIRATION_TIME = getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"); + +function parseDurationToSeconds(value: string): number { + const normalized = value.trim().toLowerCase(); + const match = normalized.match(/^(\d+(?:\.\d+)?)(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)?$/); + if (match == null) { + throw new HexclaveAssertionError("Unsupported STACK_ACCESS_TOKEN_EXPIRATION_TIME format", { value }); + } + + const amount = Number(match[1]); + const [, unit = "s"] = match; + let multiplier: number; + if (unit === "ms") { + multiplier = 1 / 1000; + } else if (unit === "s" || unit === "sec" || unit === "secs" || unit === "second" || unit === "seconds") { + multiplier = 1; + } else if (unit === "m" || unit === "min" || unit === "mins" || unit === "minute" || unit === "minutes") { + multiplier = 60; + } else if (unit === "h" || unit === "hr" || unit === "hrs" || unit === "hour" || unit === "hours") { + multiplier = 60 * 60; + } else { + multiplier = 60 * 60 * 24; + } + return Math.max(0, Math.round(amount * multiplier)); +} + +export const ACCESS_TOKEN_EXPIRATION_SECONDS = parseDurationToSeconds(ACCESS_TOKEN_EXPIRATION_TIME); const accessTokenSchema = yupObject({ projectId: yupString().defined(), @@ -401,7 +428,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Genera return await signJWT({ issuer: getIssuer(options.tenancy.project.id, userType, options.apiUrl), audience: getAudience(options.tenancy.project.id, userType), - expirationTime: getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"), + expirationTime: ACCESS_TOKEN_EXPIRATION_TIME, payload, }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page-client.tsx new file mode 100644 index 000000000..2acc9d58c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page-client.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Button, Input } from "@/components/ui"; +import { DesignAlert, DesignCard } from "@/components/design-components"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { hexclaveAppInternalsSymbol, useStackApp, useUser } from "@hexclave/next"; +import { useEffect, useMemo, useState } from "react"; +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { usePathname, useSearchParams } from "next/navigation"; + +function isTokenResponse(value: unknown): value is { access_token: string, refresh_token: string } { + return typeof value === "object" && value != null + && Reflect.get(value, "access_token") != null + && typeof Reflect.get(value, "access_token") === "string" + && Reflect.get(value, "refresh_token") != null + && typeof Reflect.get(value, "refresh_token") === "string"; +} + +export default function PageClient() { + const hexclaveAdminApp = useAdminApp(); + const project = hexclaveAdminApp.useProject(); + const config = project.useConfig(); + const app = useStackApp(); + const user = useUser(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const claimAttemptToken = searchParams.get("claim_attempt_token"); + const [userCode, setUserCode] = useState(""); + const [submitError, setSubmitError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (user == null) { + app.redirectToSignIn().catch(() => undefined); + } + }, [app, user]); + + const userLabel = user?.displayName ?? user?.primaryEmail ?? "your account"; + const isValidUserCode = useMemo(() => /^\d{6}$/.test(userCode), [userCode]); + + const handleSubmit = async () => { + setSubmitError(null); + + if (claimAttemptToken == null) { + setSubmitError("Missing claim_attempt_token in the URL."); + throw new Error("Missing claim_attempt_token in the URL."); + } + + if (!isValidUserCode) { + setSubmitError("Enter the 6-digit user code shown by the agent."); + throw new Error("Enter the 6-digit user code shown by the agent."); + } + + const response = await app[hexclaveAppInternalsSymbol].sendRequest( + `/api/v1/projects/${project.id}/agent/identity/claim/complete`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + claim_attempt_token: claimAttemptToken, + user_code: userCode, + }), + }, + "client", + ); + + if (!response.ok) { + const text = await response.text(); + setSubmitError(text); + throw new Error(text); + } + + const data: unknown = await response.json(); + if (!isTokenResponse(data)) { + throw new Error("Unexpected completion response"); + } + + await app[hexclaveAppInternalsSymbol].signInWithTokens({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + }); + setSuccess(true); + }; + + return ( + + + + {submitError != null && ( + + )} + {success ? ( + + ) : ( +
+
+ + setUserCode(event.target.value.replace(/\s+/g, ""))} + inputMode="numeric" + autoComplete="one-time-code" + placeholder="123456" + /> +
+
+ + + {pathname} + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page.tsx new file mode 100644 index 000000000..4f581d33a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/claim/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Claim Agent Auth Registration", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx new file mode 100644 index 000000000..8e8f145c7 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page-client.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { Suspense, useMemo, useState } from "react"; + +import { useUpdateConfig } from "@/components/config-update"; +import { DesignAlert, DesignCard, DesignEditableGrid, type DesignEditableGridItem } from "@/components/design-components"; +import { Switch } from "@/components/ui"; +import { SparkleIcon, EnvelopeSimpleIcon, UserGearIcon } from "@phosphor-icons/react"; +import { useMetricsOrThrow } from "@/lib/hexclave-app-internals"; +import { useAdminApp } from "../use-admin-app"; +import { PageLayout } from "../page-layout"; + +export default function PageClient() { + const hexclaveAdminApp = useAdminApp(); + const project = hexclaveAdminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + + const configuredAppEnabled = config.apps.installed["agent-auth"]?.enabled === true; + const configuredServiceAuthEnabled = config.agentAuth.identityTypes.serviceAuth === true; + const configuredAnonymousEnabled = config.agentAuth.identityTypes.anonymous === true; + + const [localAppEnabled, setLocalAppEnabled] = useState(undefined); + const [localServiceAuthEnabled, setLocalServiceAuthEnabled] = useState(undefined); + const [localAnonymousEnabled, setLocalAnonymousEnabled] = useState(undefined); + + const appEnabled = localAppEnabled ?? configuredAppEnabled; + const serviceAuthEnabled = localServiceAuthEnabled ?? configuredServiceAuthEnabled; + const anonymousEnabled = localAnonymousEnabled ?? configuredAnonymousEnabled; + + const hasChanges = useMemo(() => + localAppEnabled !== undefined || localServiceAuthEnabled !== undefined || localAnonymousEnabled !== undefined, + [localAppEnabled, localServiceAuthEnabled, localAnonymousEnabled]); + + const modifiedKeys = useMemo(() => new Set([ + ...(localAppEnabled !== undefined ? ["app-enabled"] : []), + ...(localServiceAuthEnabled !== undefined ? ["service-auth"] : []), + ...(localAnonymousEnabled !== undefined ? ["anonymous"] : []), + ]), [localAnonymousEnabled, localAppEnabled, localServiceAuthEnabled]); + + const handleSave = async () => { + const configUpdate: Record = {}; + if (localAppEnabled !== undefined) { + configUpdate["apps.installed.agent-auth.enabled"] = localAppEnabled; + } + if (localServiceAuthEnabled !== undefined) { + configUpdate["agentAuth.identityTypes.serviceAuth"] = localServiceAuthEnabled; + } + if (localAnonymousEnabled !== undefined) { + configUpdate["agentAuth.identityTypes.anonymous"] = localAnonymousEnabled; + } + await updateConfig({ + adminApp: hexclaveAdminApp, + configUpdate, + pushable: true, + }); + setLocalAppEnabled(undefined); + setLocalServiceAuthEnabled(undefined); + setLocalAnonymousEnabled(undefined); + }; + + const handleDiscard = () => { + setLocalAppEnabled(undefined); + setLocalServiceAuthEnabled(undefined); + setLocalAnonymousEnabled(undefined); + }; + + const items: DesignEditableGridItem[] = [ + { + itemKey: "app-enabled", + type: "custom", + icon: , + name: "Agent Auth Enabled", + tooltip: "Enable project-scoped agent-auth discovery documents and backend routes.", + children: ( + { + if (checked === configuredAppEnabled) { + setLocalAppEnabled(undefined); + } else { + setLocalAppEnabled(checked); + } + }} + /> + ), + }, + { + itemKey: "service-auth", + type: "custom", + icon: , + name: "Service Auth", + tooltip: "Allow email-backed registrations for agents that can prove ownership of a service email address.", + children: ( + { + if (checked === configuredServiceAuthEnabled) { + setLocalServiceAuthEnabled(undefined); + } else { + setLocalServiceAuthEnabled(checked); + } + }} + /> + ), + }, + { + itemKey: "anonymous", + type: "custom", + icon: , + name: "Anonymous", + tooltip: "Allow agents to register without an initial human identity and defer claim until later.", + children: ( + { + if (checked === configuredAnonymousEnabled) { + setLocalAnonymousEnabled(undefined); + } else { + setLocalAnonymousEnabled(checked); + } + }} + /> + ), + }, + ]; + + return ( + + + This app exposes the project-scoped discovery documents and the hosted manifest used by agentic clients. +

+ The enable switch controls whether the project serves the public metadata at all. The identity toggles control which registration flows appear in discovery. + } + /> + + + + + + }> + + +
+ ); +} + +function AgentAuthMetricsCard() { + const hexclaveAdminApp = useAdminApp(); + const metrics = useMetricsOrThrow(hexclaveAdminApp, false); + const agentAuthMetrics = metrics.auth_overview.agent_auth; + + return ( + +
+ + + + +
+
+ ); +} + +function MetricItem({ label, value }: { label: string, value: number }) { + return ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx new file mode 100644 index 000000000..84cdebde1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/agent-auth-app/page.tsx @@ -0,0 +1,5 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7f592e49d..0202ccbe8 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -191,6 +191,18 @@ export const ALL_APPS_FRONTEND = { ), }, + "agent-auth": { + icon: SparkleIcon, + href: "agent-auth-app", + screenshots: getScreenshots('auth', 1), + storeDescription: ( + <> +

Agent Auth lets third-party agents discover your project, register identities, and complete a claim ceremony when human approval is needed.

+

Enable the project-scoped discovery documents, then choose whether email-backed service auth and anonymous registrations are available.

+

The app keeps the future claim, exchange, and revocation paths tied to a single per-project surface area.

+ + ), + }, payments: { icon: CreditCardIcon, href: "payments", diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index 45ab1985c..f053e6da8 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -139,6 +139,15 @@ function applyMetricsResponseDefaults(body: MetricsResponse): MetricsResponse { top_operating_systems: rawAnalytics.top_operating_systems ?? [], top_devices: rawAnalytics.top_devices ?? [], }, + auth_overview: { + ...body.auth_overview, + agent_auth: body.auth_overview.agent_auth ?? { + total_registrations: 0, + completed_claims: 0, + new_user_signups: 0, + api_keys_issued: 0, + }, + }, }; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/agent-auth.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/agent-auth.test.ts new file mode 100644 index 000000000..c3aa1a627 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/agent-auth.test.ts @@ -0,0 +1,462 @@ +import { urlString } from "@hexclave/shared/dist/utils/urls"; +import { describe, expect } from "vitest"; +import { it } from "../../../../helpers"; +import { Auth, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; + +const agentAuthEnabledConfig = { + "apps.installed.agent-auth": { + enabled: true, + }, + "agentAuth.identityTypes.serviceAuth": true, + "agentAuth.identityTypes.anonymous": true, +} as const; + +function normalizeProjectUrls(value: string, projectId: string) { + return value.replaceAll(projectId, ""); +} + +function normalizeDiscoveryBody(body: { + resource: string, + resource_name: string, + authorization_servers: string[], + scopes_supported: string[], + bearer_methods_supported: string[], + agent_auth?: { skill: string, identity_endpoint: string, claim_endpoint: string, events_endpoint: string, identity_types_supported: string[], events_supported: string[] }, +}, projectId: string) { + return { + ...body, + resource: normalizeProjectUrls(body.resource, projectId), + authorization_servers: body.authorization_servers.map((value) => normalizeProjectUrls(value, projectId)), + ...(body.agent_auth == null ? {} : { + agent_auth: { + ...body.agent_auth, + skill: normalizeProjectUrls(body.agent_auth.skill, projectId), + identity_endpoint: normalizeProjectUrls(body.agent_auth.identity_endpoint, projectId), + claim_endpoint: normalizeProjectUrls(body.agent_auth.claim_endpoint, projectId), + events_endpoint: normalizeProjectUrls(body.agent_auth.events_endpoint, projectId), + }, + }), + }; +} + +function getClaimAttemptTokenFromVerificationUri(verificationUri: string) { + const url = new URL(verificationUri); + const claimAttemptToken = url.searchParams.get("claim_attempt_token"); + if (claimAttemptToken == null) { + throw new Error(`verification_uri is missing claim_attempt_token: ${verificationUri}`); + } + return claimAttemptToken; +} + +async function createProject() { + const { projectId, adminAccessToken } = await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + allow_user_api_keys: false, + allow_team_api_keys: false, + }, + }); + backendContext.set({ + projectKeys: { + ...backendContext.value.projectKeys, + adminAccessToken, + }, + }); + return { projectId, adminAccessToken }; +} + +async function enableAgentAuth(adminAccessToken: string, extraConfig: Record = {}) { + const response = await niceBackendFetch("/api/v1/internal/config/override/branch", { + accessType: "admin", + method: "PATCH", + headers: { + "x-stack-admin-access-token": adminAccessToken, + }, + body: { + config_override_string: JSON.stringify({ + ...agentAuthEnabledConfig, + ...extraConfig, + }), + }, + }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); +} + +async function registerAnonymous(projectId: string) { + return await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity`, { + method: "POST", + accessType: "client", + body: { + type: "anonymous", + }, + }); +} + +async function registerServiceAuth(projectId: string, loginHint: string) { + return await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity`, { + method: "POST", + accessType: "client", + body: { + type: "service_auth", + login_hint: loginHint, + }, + }); +} + +async function pollClaimToken(projectId: string, claimToken: string) { + return await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/token`, { + method: "POST", + accessType: "client", + body: { + grant_type: "urn:workos:agent-auth:grant-type:claim", + claim_token: claimToken, + }, + }); +} + +describe("agent auth vertical slice", () => { + it("handles discovery enablement and disablement", async ({ expect }) => { + const { projectId } = await createProject(); + + const disabledDiscoveryResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/.well-known/oauth-protected-resource`, { + accessType: "client", + }); + expect(disabledDiscoveryResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 404, + "body": "Project not found", + "headers": Headers {