feat(agent-auth): add AuthMD agentic-registration app

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-07-07 19:31:30 +00:00
parent 105f03fb94
commit 5fb4a93f2c
30 changed files with 3406 additions and 6 deletions

View File

@ -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;

View File

@ -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<ReturnType<typeof preMigration>>) => {
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/);
};

View File

@ -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

View File

@ -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(

View File

@ -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),
},
};
}

View File

@ -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<SmartResponse>().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],
},
},
};
},
});

View File

@ -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<SmartResponse>().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"],
},
};
},
});

View File

@ -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<SmartResponse>().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",
});
},
});

View File

@ -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<SmartResponse>().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(),
});
},
});

View File

@ -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<SmartResponse>().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,
},
});
},
});

View File

@ -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<SmartResponse>().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,
});
},
});

View File

@ -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<SmartResponse>().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 };
},
});

View File

@ -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<SmartResponse>().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",
});
},
});

View File

@ -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<SmartResponse>().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",
},
}),
};
},
});

View File

@ -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<AgentAuthRegistrationRow | null> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(Prisma.sql`
${selectColumns(schema)}
WHERE "claimToken" = ${claimToken}
LIMIT 1
`);
return rows[0] ?? null;
}
export async function getAgentAuthRegistrationByClaimAttemptToken(prisma: PrismaClientWithReplica, schema: string, claimAttemptToken: string): Promise<AgentAuthRegistrationRow | null> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(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<AgentAuthRegistrationRow> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(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<AgentAuthRegistrationRow | null> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(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<AgentAuthRegistrationRow | null> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(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<AgentAuthRegistrationRow | null> {
const rows = await prisma.$queryRaw<AgentAuthRegistrationRow[]>(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;
}

View File

@ -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 <access_token>',
'```',
'',
'## 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=<access_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");
}

View File

@ -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<T extends EventType[]>(
});
// 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<string, unknown>;
@ -368,6 +400,29 @@ export async function logEvent<T extends EventType[]>(
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 });
}

View File

@ -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,
});
}

View File

@ -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<string | null>(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 (
<AppEnabledGuard appId="agent-auth">
<PageLayout title="Claim Agent Auth Registration" description="Complete the agent registration claim step for this project">
<DesignCard
title="Complete the claim"
subtitle={`You're signed in as ${userLabel}. Enter the 6-digit code shown to the agent.`}
icon={CheckCircleIcon}
glassmorphic
>
{submitError != null && (
<DesignAlert variant="error" title="Claim failed" description={submitError} />
)}
{success ? (
<DesignAlert
variant="success"
title="Claim completed"
description="The registration has been bound to your account."
/>
) : (
<div className="flex flex-col gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="user-code">
6-digit code
</label>
<Input
id="user-code"
value={userCode}
onChange={(event) => setUserCode(event.target.value.replace(/\s+/g, ""))}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
/>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSubmit} disabled={!isValidUserCode}>
Complete claim
</Button>
<span className="text-sm text-muted-foreground">
{pathname}
</span>
</div>
</div>
)}
</DesignCard>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Claim Agent Auth Registration",
};
export default function Page() {
return <PageClient />;
}

View File

@ -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<boolean | undefined>(undefined);
const [localServiceAuthEnabled, setLocalServiceAuthEnabled] = useState<boolean | undefined>(undefined);
const [localAnonymousEnabled, setLocalAnonymousEnabled] = useState<boolean | undefined>(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<string, boolean> = {};
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: <SparkleIcon className="h-3.5 w-3.5" />,
name: "Agent Auth Enabled",
tooltip: "Enable project-scoped agent-auth discovery documents and backend routes.",
children: (
<Switch
checked={appEnabled}
onCheckedChange={(checked) => {
if (checked === configuredAppEnabled) {
setLocalAppEnabled(undefined);
} else {
setLocalAppEnabled(checked);
}
}}
/>
),
},
{
itemKey: "service-auth",
type: "custom",
icon: <EnvelopeSimpleIcon className="h-3.5 w-3.5" />,
name: "Service Auth",
tooltip: "Allow email-backed registrations for agents that can prove ownership of a service email address.",
children: (
<Switch
checked={serviceAuthEnabled}
onCheckedChange={(checked) => {
if (checked === configuredServiceAuthEnabled) {
setLocalServiceAuthEnabled(undefined);
} else {
setLocalServiceAuthEnabled(checked);
}
}}
/>
),
},
{
itemKey: "anonymous",
type: "custom",
icon: <UserGearIcon className="h-3.5 w-3.5" />,
name: "Anonymous",
tooltip: "Allow agents to register without an initial human identity and defer claim until later.",
children: (
<Switch
checked={anonymousEnabled}
onCheckedChange={(checked) => {
if (checked === configuredAnonymousEnabled) {
setLocalAnonymousEnabled(undefined);
} else {
setLocalAnonymousEnabled(checked);
}
}}
/>
),
},
];
return (
<PageLayout title="Agent Auth" description="Configure agent-auth discovery and registration settings for this project">
<DesignAlert
variant="info"
title="About Agent Auth"
description={<>
This app exposes the project-scoped discovery documents and the hosted manifest used by agentic clients.
<br /><br />
The enable switch controls whether the project serves the public metadata at all. The identity toggles control which registration flows appear in discovery.
</>}
/>
<DesignCard
title="Agent Auth Settings"
subtitle="Enable the app and choose which identity types discovery should advertise"
icon={SparkleIcon}
glassmorphic
>
<DesignEditableGrid
items={items}
columns={1}
deferredSave
hasChanges={hasChanges}
onSave={handleSave}
onDiscard={handleDiscard}
externalModifiedKeys={modifiedKeys}
className="gap-y-3"
/>
</DesignCard>
<Suspense fallback={<DesignCard title="Agent Auth Metrics" subtitle="Loading agent-auth usage stats..." icon={SparkleIcon} glassmorphic />}>
<AgentAuthMetricsCard />
</Suspense>
</PageLayout>
);
}
function AgentAuthMetricsCard() {
const hexclaveAdminApp = useAdminApp();
const metrics = useMetricsOrThrow(hexclaveAdminApp, false);
const agentAuthMetrics = metrics.auth_overview.agent_auth;
return (
<DesignCard
title="Agent Auth Metrics"
subtitle="Read-only project totals from the internal metrics endpoint"
icon={SparkleIcon}
glassmorphic
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricItem label="Registrations" value={agentAuthMetrics.total_registrations} />
<MetricItem label="Completed claims" value={agentAuthMetrics.completed_claims} />
<MetricItem label="New-user signups" value={agentAuthMetrics.new_user_signups} />
<MetricItem label="API keys issued" value={agentAuthMetrics.api_keys_issued} />
</div>
</DesignCard>
);
}
function MetricItem({ label, value }: { label: string, value: number }) {
return (
<div className="rounded-xl border border-border/60 bg-background/40 p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-2 text-2xl font-semibold tabular-nums">{value.toLocaleString()}</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import PageClient from "./page-client";
export default function Page() {
return <PageClient />;
}

View File

@ -191,6 +191,18 @@ export const ALL_APPS_FRONTEND = {
</>
),
},
"agent-auth": {
icon: SparkleIcon,
href: "agent-auth-app",
screenshots: getScreenshots('auth', 1),
storeDescription: (
<>
<p>Agent Auth lets third-party agents discover your project, register identities, and complete a claim ceremony when human approval is needed.</p>
<p>Enable the project-scoped discovery documents, then choose whether email-backed service auth and anonymous registrations are available.</p>
<p>The app keeps the future claim, exchange, and revocation paths tied to a single per-project surface area.</p>
</>
),
},
payments: {
icon: CreditCardIcon,
href: "payments",

View File

@ -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,
},
},
};
}

View File

@ -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, "<project_id>");
}
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<string, unknown> = {}) {
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 { <some fields may have been hidden> },
}
`);
const disabledAuthorizationResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/.well-known/oauth-authorization-server`, {
accessType: "client",
});
expect(disabledAuthorizationResponse.status).toBe(404);
const disabledAuthMdResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/auth.md`, {
accessType: "client",
});
expect(disabledAuthMdResponse.status).toBe(404);
await enableAgentAuth((backendContext.value.projectKeys as { adminAccessToken: string }).adminAccessToken);
const discoveryResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/.well-known/oauth-protected-resource`, {
accessType: "client",
});
expect(discoveryResponse.status).toBe(200);
expect(normalizeDiscoveryBody(discoveryResponse.body, projectId)).toMatchInlineSnapshot(`
{
"authorization_servers": ["http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>"],
"bearer_methods_supported": ["header"],
"resource": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>",
"resource_name": "New Project",
"scopes_supported": ["agent.auth"],
}
`);
const authorizationResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/.well-known/oauth-authorization-server`, {
accessType: "client",
});
expect(authorizationResponse.status).toBe(200);
expect(normalizeDiscoveryBody(authorizationResponse.body, projectId)).toMatchInlineSnapshot(`
{
"agent_auth": {
"claim_endpoint": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>/agent/identity/claim",
"events_endpoint": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>/agent/event/notify",
"events_supported": ["https://schemas.workos.com/events/agent/auth/identity/assertion/revoked"],
"identity_endpoint": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>/agent/identity",
"identity_types_supported": [
"anonymous",
"service_auth",
],
"skill": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>/auth.md",
},
"authorization_servers": ["http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>"],
"bearer_methods_supported": ["header"],
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:workos:agent-auth:grant-type:claim",
],
"issuer": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<stripped UUID>",
"resource": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<project_id>",
"resource_name": "New Project",
"revocation_endpoint": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<stripped UUID>/agent/revoke",
"scopes_supported": ["agent.auth"],
"token_endpoint": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>02/api/v1/projects/<stripped UUID>/agent/token",
}
`);
const authMdResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/auth.md`, {
accessType: "client",
});
expect(authMdResponse.status).toBe(200);
expect(String(authMdResponse.body)).toContain("/agent/identity");
expect(String(authMdResponse.body)).toContain("/agent/api-keys");
});
it("returns config-toggle errors for disabled identity types", async ({ expect }) => {
const { projectId, adminAccessToken } = await createProject();
await enableAgentAuth(adminAccessToken, {
"agentAuth.identityTypes.serviceAuth": false,
"agentAuth.identityTypes.anonymous": false,
});
const serviceAuthResponse = await registerServiceAuth(projectId, "agent@example.com");
expect(serviceAuthResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": { "error": "service_auth_not_enabled" },
"headers": Headers { <some fields may have been hidden> },
}
`);
const anonymousResponse = await registerAnonymous(projectId);
expect(anonymousResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": { "error": "anonymous_not_enabled" },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("supports the anonymous claim and jwt-bearer exchange flow", async ({ expect }) => {
const { projectId, adminAccessToken } = await createProject();
await enableAgentAuth(adminAccessToken);
const registrationResponse = await registerAnonymous(projectId);
expect(registrationResponse.status).toBe(200);
expect(registrationResponse.body).toMatchObject({
registration: {
type: "anonymous",
status: "pending",
},
identity_assertion: expect.any(String),
access_token: expect.any(String),
claim_token: expect.any(String),
pre_claim_scopes: ["agent.auth"],
post_claim_scopes: ["agent.auth"],
});
backendContext.set({
userAuth: {
accessToken: registrationResponse.body.access_token as string,
refreshToken: registrationResponse.body.identity_assertion as string,
},
});
const meResponse = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
});
expect(meResponse.status).toBe(200);
expect(meResponse.body.is_anonymous).toBe(true);
const exchangeResponse = await pollClaimToken(projectId, registrationResponse.body.claim_token as string);
expect(exchangeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"error": "authorization_pending",
"interval": 5,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const jwtBearerResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/token`, {
method: "POST",
accessType: "client",
body: {
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: registrationResponse.body.identity_assertion,
},
});
expect(jwtBearerResponse.status).toBe(200);
expect(jwtBearerResponse.body).toMatchObject({
token_type: "Bearer",
access_token: expect.any(String),
identity_assertion: registrationResponse.body.identity_assertion,
scope: "agent.auth",
});
});
it("completes the service-auth claim ceremony and supports claim security", async ({ expect }) => {
const { projectId, adminAccessToken } = await createProject();
await enableAgentAuth(adminAccessToken);
const loginHintMailbox = createMailbox();
const registrationResponse = await registerServiceAuth(projectId, loginHintMailbox.emailAddress);
expect(registrationResponse.status).toBe(200);
const claimAttemptToken = getClaimAttemptTokenFromVerificationUri(registrationResponse.body.claim.verification_uri);
expect(registrationResponse.body.claim).toMatchObject({
user_code: expect.any(String),
verification_uri: expect.stringContaining(`/projects/${projectId}/agent-auth-app/claim`),
interval: 5,
expires_in: 600,
});
await Auth.fastSignUp({
primary_email: loginHintMailbox.emailAddress,
primary_email_verified: true,
});
const wrongCodeResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity/claim/complete`, {
method: "POST",
accessType: "client",
body: {
claim_attempt_token: claimAttemptToken,
user_code: "000000",
},
});
expect(wrongCodeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": { "error": "invalid_claim_token" },
"headers": Headers { <some fields may have been hidden> },
}
`);
const wrongUserMailbox = createMailbox();
await Auth.fastSignUp({
primary_email: wrongUserMailbox.emailAddress,
primary_email_verified: true,
});
const wrongEmailResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity/claim/complete`, {
method: "POST",
accessType: "client",
body: {
claim_attempt_token: claimAttemptToken,
user_code: registrationResponse.body.claim.user_code,
},
});
expect(wrongEmailResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": { "error": "access_denied" },
"headers": Headers { <some fields may have been hidden> },
}
`);
await Auth.fastSignUp({
primary_email: loginHintMailbox.emailAddress,
primary_email_verified: true,
});
const completeResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity/claim/complete`, {
method: "POST",
accessType: "client",
body: {
claim_attempt_token: claimAttemptToken,
user_code: registrationResponse.body.claim.user_code,
},
});
expect(completeResponse.status).toBe(200);
expect(completeResponse.body).toMatchObject({
success: true,
access_token: expect.any(String),
identity_assertion: expect.any(String),
assertion_expires: expect.any(String),
});
backendContext.set({
userAuth: {
accessToken: completeResponse.body.access_token as string,
refreshToken: completeResponse.body.identity_assertion as string,
},
});
const claimPollResponse = await pollClaimToken(projectId, registrationResponse.body.claim_token as string);
expect(claimPollResponse.status).toBe(200);
expect(claimPollResponse.body).toMatchObject({
token_type: "Bearer",
scope: "agent.auth",
access_token: expect.any(String),
identity_assertion: expect.any(String),
assertion_expires: expect.any(String),
expires_in: expect.any(Number),
});
});
it("issues API keys after the ceremony and reports app-disabled errors", async ({ expect }) => {
const { projectId, adminAccessToken } = await createProject();
await enableAgentAuth(adminAccessToken);
const email = createMailbox().emailAddress;
const registrationResponse = await registerServiceAuth(projectId, email);
const claimAttemptToken = getClaimAttemptTokenFromVerificationUri(registrationResponse.body.claim.verification_uri);
await Auth.fastSignUp({
primary_email: email,
primary_email_verified: true,
});
const completeResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/identity/claim/complete`, {
method: "POST",
accessType: "client",
body: {
claim_attempt_token: claimAttemptToken,
user_code: registrationResponse.body.claim.user_code,
},
});
expect(completeResponse.status).toBe(200);
backendContext.set({
userAuth: {
accessToken: completeResponse.body.access_token as string,
refreshToken: completeResponse.body.identity_assertion as string,
},
});
const disabledApiKeysResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/api-keys`, {
method: "POST",
accessType: "client",
body: {
description: "Agent API key",
expires_at_millis: null,
},
});
expect(disabledApiKeysResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"enable_url": "http://localhost:<$NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX>01/projects/<stripped UUID>/api-keys-app",
"error": "api_keys_app_not_enabled",
"error_description": "Agent API key issuance is not enabled for this project.",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
await Project.updateCurrent(adminAccessToken, {
config: {
allow_user_api_keys: true,
},
});
await enableAgentAuth(adminAccessToken, {
"apps.installed.api-keys": {
enabled: true,
},
});
const enabledApiKeysResponse = await niceBackendFetch(urlString`/api/v1/projects/${projectId}/agent/api-keys`, {
method: "POST",
accessType: "client",
body: {
description: "Agent API key",
expires_at_millis: null,
},
});
expect(enabledApiKeysResponse.status).toBe(200);
expect(enabledApiKeysResponse.body).toMatchObject({
description: "Agent API key",
is_public: false,
type: "user",
value: expect.any(String),
user_id: expect.any(String),
});
});
});

View File

@ -0,0 +1,604 @@
#!/usr/bin/env node
type ProjectKeys = {
projectId: string;
publishableClientKey: string;
secretServerKey: string;
superSecretAdminKey: string;
};
type DemoProjectContext = ProjectKeys & {
adminAccessToken: string;
};
type RequestOptions = {
method?: string;
accessType?: "client" | "server" | "admin";
body?: unknown;
headers?: Record<string, string>;
project?: ProjectKeys | undefined;
accessToken?: string;
refreshToken?: string;
};
type JsonResponse<T> = {
status: number;
body: T;
headers: Headers;
};
type SignUpResponse = {
access_token: string;
refresh_token: string;
user_id: string;
};
type CreatedProjectResponse = {
id: string;
};
type ProjectApiKeyResponse = {
publishable_client_key: string;
secret_server_key: string;
super_secret_admin_key: string;
};
type ClaimCompletionResponse = {
access_token: string;
refresh_token: string;
user_id: string;
};
type AgentIdentityRegistrationResponse = {
registration: {
type: "anonymous" | "service_auth";
status: string;
};
claim_token: string;
identity_assertion?: string;
access_token?: string;
claim?: {
claim_attempt_token: string;
user_code: string;
verification_uri: string;
expires_in: number;
};
pre_claim_scopes?: string[];
post_claim_scopes?: string[];
};
type ClaimPollResponse = {
access_token?: string;
identity_assertion?: string;
assertion_expires?: number;
error?: string;
error_description?: string;
expires_in?: number;
scope?: string;
token_type?: string;
};
function resolveEnvVar(primary: string, fallback: string): string | undefined {
const primaryValue = process.env[primary];
const fallbackValue = process.env[fallback];
if (primaryValue != null && fallbackValue != null && primaryValue !== fallbackValue) {
throw new Error(`Environment variables ${primary} and ${fallback} are both set to different values.`);
}
return primaryValue ?? fallbackValue;
}
function resolveAnyEnv(...names: string[]) {
let resolved: string | undefined;
for (const name of names) {
const value = process.env[name];
if (value == null) {
continue;
}
if (resolved != null && resolved !== value) {
throw new Error(`Environment variables ${names.join(", ")} are both set to different values.`);
}
resolved = value;
}
return resolved;
}
function requiredEnv(name: string, fallback?: string): string {
const value = fallback == null ? process.env[name] : resolveEnvVar(name, fallback);
if (value == null || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}${fallback == null ? "" : ` (or ${fallback})`}`);
}
return value;
}
function withPortPrefix() {
return process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX ?? "81";
}
function apiUrl() {
return resolveEnvVar("HEXCLAVE_API_URL", "STACK_API_URL") ?? `http://localhost:${withPortPrefix()}02`;
}
function dashboardUrl() {
return resolveEnvVar("HEXCLAVE_APP_URL", "STACK_APP_URL") ?? `http://localhost:${withPortPrefix()}01`;
}
function internalProjectKeys(): ProjectKeys {
return {
projectId: resolveAnyEnv("HEXCLAVE_INTERNAL_PROJECT_ID", "STACK_INTERNAL_PROJECT_ID") ?? "internal",
publishableClientKey: resolveAnyEnv(
"HEXCLAVE_INTERNAL_PROJECT_CLIENT_KEY",
"HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY",
"STACK_INTERNAL_PROJECT_CLIENT_KEY",
"STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY",
) ?? "this-publishable-client-key-is-for-local-development-only",
secretServerKey: resolveAnyEnv(
"HEXCLAVE_INTERNAL_PROJECT_SERVER_KEY",
"HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY",
"STACK_INTERNAL_PROJECT_SERVER_KEY",
"STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY",
) ?? "this-secret-server-key-is-for-local-development-only",
superSecretAdminKey: resolveAnyEnv(
"HEXCLAVE_INTERNAL_PROJECT_ADMIN_KEY",
"HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY",
"STACK_INTERNAL_PROJECT_ADMIN_KEY",
"STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY",
) ?? "this-super-secret-admin-key",
};
}
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<JsonResponse<T>> {
const url = new URL(path, apiUrl());
const headers: Record<string, string> = {
"x-stack-disable-artificial-development-delay": "yes",
"x-stack-development-disable-extended-logging": "yes",
};
if (options.accessType != null) {
headers["x-stack-access-type"] = options.accessType;
}
if (options.project != null) {
headers["x-stack-project-id"] = options.project.projectId;
headers["x-stack-publishable-client-key"] = options.project.publishableClientKey;
headers["x-stack-secret-server-key"] = options.project.secretServerKey;
headers["x-stack-super-secret-admin-key"] = options.project.superSecretAdminKey;
}
if (options.accessToken != null) {
headers[options.accessType === "admin" ? "x-stack-admin-access-token" : "x-stack-access-token"] = options.accessToken;
}
if (options.refreshToken != null) {
headers["x-stack-refresh-token"] = options.refreshToken;
}
for (const [key, value] of Object.entries(options.headers ?? {})) {
headers[key] = value;
}
let body: BodyInit | undefined;
if (options.body != null) {
headers["content-type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(url, {
method: options.method ?? "GET",
headers,
body,
});
const contentType = response.headers.get("content-type") ?? "";
let responseBody: T;
if (contentType.includes("application/json")) {
responseBody = await response.json() as T;
} else {
responseBody = (await response.text()) as T;
}
return {
status: response.status,
body: responseBody,
headers: response.headers,
};
}
function logStep(title: string, details?: string) {
console.log(`\n=== ${title} ===`);
if (details != null) {
console.log(details);
}
}
function truncate(value: string, length = 24) {
return value.length <= length ? value : `${value.slice(0, length)}`;
}
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 createBootstrapProject(): Promise<DemoProjectContext> {
logStep("Bootstrap project", "Signing up a creator user in the internal project...");
const internal = internalProjectKeys();
const creatorEmail = `agent-demo-${Date.now()}@stack-generated.example.com`;
const signUpResponse = await requestJson<SignUpResponse>("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
project: internal,
body: {
email: creatorEmail,
password: "agent-demo-password-123",
verification_callback_url: "http://localhost:12345/some-callback-url",
bot_challenge_token: "mock-turnstile-ok:sign_up_with_credential",
},
});
if (signUpResponse.status !== 200) {
throw new Error(`Creator sign-up failed: ${signUpResponse.status} ${JSON.stringify(signUpResponse.body)}`);
}
const creatorAccessToken = signUpResponse.body.access_token;
const creatorMe = await requestJson<{ selected_team_id: string }>("/api/v1/users/me", {
accessType: "client",
project: internal,
accessToken: creatorAccessToken,
});
if (creatorMe.status !== 200) {
throw new Error(`Could not read creator team: ${creatorMe.status} ${JSON.stringify(creatorMe.body)}`);
}
const createProjectResponse = await requestJson<CreatedProjectResponse>("/api/v1/internal/projects", {
method: "POST",
accessType: "client",
project: internal,
accessToken: creatorAccessToken,
body: {
display_name: "Agent Auth Demo Project",
owner_team_id: creatorMe.body.selected_team_id,
config: {
credential_enabled: true,
allow_localhost: true,
},
},
});
if (createProjectResponse.status !== 201) {
throw new Error(`Project creation failed: ${createProjectResponse.status} ${JSON.stringify(createProjectResponse.body)}`);
}
const demoProject: ProjectKeys = {
projectId: createProjectResponse.body.id,
publishableClientKey: "",
secretServerKey: "",
superSecretAdminKey: "",
};
const projectKeysResponse = await requestJson<ProjectApiKeyResponse>("/api/v1/internal/api-keys", {
method: "POST",
accessType: "admin",
project: { ...internal, projectId: createProjectResponse.body.id },
accessToken: creatorAccessToken,
body: {
description: "agent auth demo project keys",
has_publishable_client_key: true,
has_secret_server_key: true,
has_super_secret_admin_key: true,
expires_at_millis: Date.now() + 1000 * 60 * 60 * 24,
},
});
if (projectKeysResponse.status !== 200) {
throw new Error(`Project key creation failed: ${projectKeysResponse.status} ${JSON.stringify(projectKeysResponse.body)}`);
}
return {
...demoProject,
publishableClientKey: projectKeysResponse.body.publishable_client_key,
secretServerKey: projectKeysResponse.body.secret_server_key,
superSecretAdminKey: projectKeysResponse.body.super_secret_admin_key,
adminAccessToken: creatorAccessToken,
};
}
async function ensureProjectContext(): Promise<DemoProjectContext> {
const targetProjectId = process.env.HEXCLAVE_AGENT_AUTH_DEMO_PROJECT_ID ?? process.env.STACK_AGENT_AUTH_DEMO_PROJECT_ID;
const targetPublishableClientKey = process.env.HEXCLAVE_AGENT_AUTH_DEMO_PUBLISHABLE_CLIENT_KEY ?? process.env.STACK_AGENT_AUTH_DEMO_PUBLISHABLE_CLIENT_KEY;
const targetSecretServerKey = process.env.HEXCLAVE_AGENT_AUTH_DEMO_SECRET_SERVER_KEY ?? process.env.STACK_AGENT_AUTH_DEMO_SECRET_SERVER_KEY;
const targetSuperSecretAdminKey = process.env.HEXCLAVE_AGENT_AUTH_DEMO_SUPER_SECRET_ADMIN_KEY ?? process.env.STACK_AGENT_AUTH_DEMO_SUPER_SECRET_ADMIN_KEY;
const targetAdminAccessToken = process.env.HEXCLAVE_AGENT_AUTH_DEMO_ADMIN_ACCESS_TOKEN ?? process.env.STACK_AGENT_AUTH_DEMO_ADMIN_ACCESS_TOKEN;
if (
targetProjectId != null &&
targetPublishableClientKey != null &&
targetSecretServerKey != null &&
targetSuperSecretAdminKey != null &&
targetAdminAccessToken != null
) {
return {
projectId: targetProjectId,
publishableClientKey: targetPublishableClientKey,
secretServerKey: targetSecretServerKey,
superSecretAdminKey: targetSuperSecretAdminKey,
adminAccessToken: targetAdminAccessToken,
};
}
return await createBootstrapProject();
}
async function ensureAgentAuthEnabled(project: DemoProjectContext) {
logStep("Enable agent auth", `Project ${project.projectId} → app enablement`);
const response = await requestJson<Record<string, unknown>>("/api/v1/internal/config/override/branch", {
method: "PATCH",
accessType: "admin",
project,
accessToken: project.adminAccessToken,
body: {
config_override_string: JSON.stringify({
apps: {
installed: {
"agent-auth": {
enabled: true,
},
},
},
agentAuth: {
identityTypes: {
serviceAuth: true,
anonymous: true,
},
},
}),
},
});
if (response.status >= 400) {
throw new Error(`Failed to enable agent auth: ${response.status} ${JSON.stringify(response.body)}`);
}
}
async function enableApiKeys(project: DemoProjectContext) {
logStep("Enable API keys", `Project ${project.projectId} → app enablement`);
const appResponse = await requestJson<Record<string, unknown>>("/api/v1/internal/config/override/branch", {
method: "PATCH",
accessType: "admin",
project,
accessToken: project.adminAccessToken,
body: {
config_override_string: JSON.stringify({
"apps.installed.api-keys": {
enabled: true,
},
}),
},
});
if (appResponse.status >= 400) {
throw new Error(`Failed to enable api keys app: ${appResponse.status} ${JSON.stringify(appResponse.body)}`);
}
const projectResponse = await requestJson<Record<string, unknown>>("/api/v1/internal/projects/current", {
method: "PATCH",
accessType: "admin",
project,
accessToken: project.adminAccessToken,
body: {
config: {
allow_user_api_keys: true,
},
},
});
if (projectResponse.status >= 400) {
throw new Error(`Failed to enable api keys: ${projectResponse.status} ${JSON.stringify(projectResponse.body)}`);
}
}
async function registerAnonymous(project: ProjectKeys) {
logStep("Anonymous registration");
const response = await requestJson<AgentIdentityRegistrationResponse>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/identity", {
method: "POST",
accessType: "client",
project,
body: {
type: "anonymous",
},
});
if (response.status !== 200) {
throw new Error(`Anonymous registration failed: ${response.status} ${JSON.stringify(response.body)}`);
}
console.log(`identity_assertion: ${truncate(response.body.identity_assertion ?? "")}`);
console.log(`claim_token: ${truncate(response.body.claim_token)}`);
console.log(`pre-claim scopes: ${(response.body.pre_claim_scopes ?? []).join(" ")}`);
return response.body;
}
async function registerServiceAuth(project: ProjectKeys, loginHint: string) {
logStep("Service auth registration");
const response = await requestJson<AgentIdentityRegistrationResponse>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/identity", {
method: "POST",
accessType: "client",
project,
body: {
type: "service_auth",
login_hint: loginHint,
},
});
if (response.status !== 200) {
throw new Error(`Service auth registration failed: ${response.status} ${JSON.stringify(response.body)}`);
}
console.log(`user_code: ${response.body.claim?.user_code}`);
console.log(`verification_uri: ${response.body.claim?.verification_uri}`);
console.log(`claim_attempt_token: ${truncate(getClaimAttemptTokenFromVerificationUri(response.body.claim?.verification_uri ?? ""))}`);
return response.body;
}
async function completeClaim(project: ProjectKeys, claimAttemptToken: string, userCode: string, accessToken: string) {
const response = await requestJson<ClaimCompletionResponse>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/identity/claim/complete", {
method: "POST",
accessType: "client",
project,
accessToken,
body: {
claim_attempt_token: claimAttemptToken,
user_code: userCode,
},
});
if (response.status !== 200) {
throw new Error(`Claim completion failed: ${response.status} ${JSON.stringify(response.body)}`);
}
return response.body;
}
async function pollClaimToken(project: ProjectKeys, claimToken: string) {
let tries = 0;
while (true) {
tries += 1;
const response = await requestJson<ClaimPollResponse>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/token", {
method: "POST",
accessType: "client",
project,
body: {
grant_type: "urn:workos:agent-auth:grant-type:claim",
claim_token: claimToken,
},
});
if (response.status === 200 && response.body.access_token != null) {
console.log(`claim poll succeeded after ${tries} attempt(s)`);
return response.body;
}
if (response.body.error === "authorization_pending" || response.body.error === "slow_down") {
console.log(`claim poll → ${response.body.error}`);
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
if (response.body.error === "expired_token") {
throw new Error("Claim token expired before completion");
}
throw new Error(`Unexpected claim poll response: ${response.status} ${JSON.stringify(response.body)}`);
}
}
async function exchangeRefreshToken(project: ProjectKeys, refreshToken: string) {
const response = await requestJson<ClaimPollResponse>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/token", {
method: "POST",
accessType: "client",
project,
body: {
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: refreshToken,
},
});
if (response.status !== 200) {
throw new Error(`JWT-bearer exchange failed: ${response.status} ${JSON.stringify(response.body)}`);
}
return response.body;
}
async function callAuthedEndpoint(project: ProjectKeys, accessToken: string) {
const response = await requestJson<Record<string, unknown>>("/api/v1/users/me", {
accessType: "client",
project,
accessToken,
});
if (response.status !== 200) {
throw new Error(`Authenticated request failed: ${response.status} ${JSON.stringify(response.body)}`);
}
return response.body;
}
async function issueApiKey(project: ProjectKeys, accessToken: string) {
const response = await requestJson<Record<string, unknown>>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/api-keys", {
method: "POST",
accessType: "client",
project,
accessToken,
body: {
description: "Agent Auth demo key",
expires_at_millis: null,
},
});
return response;
}
async function main() {
console.log("AuthMD / WorkOS agent-auth demo");
console.log(`API: ${apiUrl()}`);
console.log(`Dashboard: ${dashboardUrl()}`);
const project = await ensureProjectContext();
console.log(`\nProject: ${project.projectId}`);
await ensureAgentAuthEnabled(project);
logStep("Discovery");
const resourceResponse = await requestJson<string>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/.well-known/oauth-protected-resource", {
accessType: "client",
project,
});
console.log(await requestJson<string>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/auth.md", {
accessType: "client",
project,
}).then((res) => (typeof res.body === "string" ? res.body : JSON.stringify(res.body))).then((body) => body.slice(0, 1200)));
console.log(`protected_resource_status=${resourceResponse.status}`);
const anonymous = await registerAnonymous(project);
const claimExchange = await exchangeRefreshToken(project, anonymous.identity_assertion ?? "");
console.log(`anonymous access token: ${truncate(claimExchange.access_token ?? "")}`);
console.log(`jwt-bearer scope: ${claimExchange.scope ?? ""}`);
const serviceAuthEmail = `agent-demo-${Date.now()}@stack-generated.example.com`;
const serviceAuthRegistration = await registerServiceAuth(project, serviceAuthEmail);
const serviceAuthClaimAttemptToken = getClaimAttemptTokenFromVerificationUri(serviceAuthRegistration.claim?.verification_uri ?? "");
const matchingUser = await requestJson<{ id: string }>("/api/v1/users", {
method: "POST",
accessType: "server",
project,
body: {
display_name: "Agent Auth Demo User",
primary_email: serviceAuthEmail,
primary_email_verified: true,
},
});
if (matchingUser.status !== 201) {
throw new Error(`Matching user creation failed: ${matchingUser.status} ${JSON.stringify(matchingUser.body)}`);
}
const matchingSession = await requestJson<SignUpResponse>("/api/v1/auth/sessions", {
method: "POST",
accessType: "server",
project,
body: {
user_id: matchingUser.body.id,
},
});
if (matchingSession.status !== 200) {
throw new Error(`Matching session creation failed: ${matchingSession.status} ${JSON.stringify(matchingSession.body)}`);
}
const completed = await completeClaim(project, serviceAuthClaimAttemptToken, serviceAuthRegistration.claim?.user_code ?? "", matchingSession.body.access_token);
console.log(`claim completed user_id: ${completed.user_id}`);
const claimPoll = await pollClaimToken(project, serviceAuthRegistration.claim_token);
console.log(`service_auth access token: ${truncate(claimPoll.access_token ?? "")}`);
console.log(`identity_assertion: ${truncate(claimPoll.identity_assertion ?? "")}`);
const me = await callAuthedEndpoint(project, claimPoll.access_token ?? "");
console.log(`signed-in email: ${String(me.primary_email ?? "")}`);
const apiKeyBefore = await issueApiKey(project, claimPoll.access_token ?? "");
console.log(`api key when disabled: ${JSON.stringify(apiKeyBefore.body)}`);
await enableApiKeys(project);
const apiKeyAfter = await issueApiKey(project, claimPoll.access_token ?? "");
console.log(`api key when enabled: ${JSON.stringify(apiKeyAfter.body)}`);
const revokeResponse = await requestJson<Record<string, unknown>>("/api/v1/projects/" + encodeURIComponent(project.projectId) + "/agent/revoke", {
method: "POST",
accessType: "client",
project,
body: {
token: claimPoll.access_token,
token_type_hint: "access_token",
},
});
console.log(`revoke status: ${revokeResponse.status}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -88,6 +88,12 @@ export const ALL_APPS = {
tags: ["auth", "security", "developers"],
stage: "stable",
},
"agent-auth": {
displayName: "Agent Auth",
subtitle: "Project-scoped agent registration and discovery",
tags: ["auth", "security", "developers"],
stage: "alpha",
},
"payments": {
displayName: "Payments",
subtitle: "Payment processing and subscription management",

View File

@ -26,6 +26,12 @@ const branchSchemaFuzzerConfig = [{
user: [true, false],
}],
}],
agentAuth: [{
identityTypes: [{
serviceAuth: [true, false],
anonymous: [true, false],
}],
}],
auth: [{
allowSignUp: [true, false],
password: [{

View File

@ -88,6 +88,15 @@ const branchApiKeysSchema = yupObject({
});
// --- END NEW API Keys Schema ---
// --- NEW Agent Auth Schema ---
const branchAgentAuthSchema = yupObject({
identityTypes: yupObject({
serviceAuth: yupBoolean(),
anonymous: yupBoolean(),
}),
});
// --- END NEW Agent Auth Schema ---
// --- NEW Apps Schema ---
const appIds = Object.keys(ALL_APPS) as (keyof typeof ALL_APPS)[];
const branchAppsSchema = yupObject({
@ -313,6 +322,8 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [
apiKeys: branchApiKeysSchema,
agentAuth: branchAgentAuthSchema,
apps: branchAppsSchema,
domains: branchDomain,
@ -516,6 +527,12 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment"
}
// END
// BEGIN 2026-07-07: agentAuth.identityTypes.identityAssertion is not supported in this slice, so remove any stale override
if (isBranchOrHigher) {
res = removeProperty(res, p => p.join(".") === "agentAuth.identityTypes.identityAssertion");
}
// END
// BEGIN 2025-09-23: payments.offers is now payments.products
if (isBranchOrHigher) {
res = renameProperty(res, "payments.offers", "products");
@ -744,6 +761,13 @@ const organizationConfigDefaults = {
},
},
agentAuth: {
identityTypes: {
serviceAuth: true,
anonymous: true,
},
},
apps: {
installed: typedFromEntries(appIds.map(appId => [appId, { enabled: false }])) as Record<string, { enabled: boolean } | undefined>,
},

View File

@ -32,6 +32,17 @@ export const MetricsAuthOverviewSchema = yupObject({
daily_active_users_split: MetricsActivitySplitSchema,
daily_active_teams_split: MetricsActivitySplitSchema,
total_users_filtered: yupNumber().integer().defined(),
agent_auth: yupObject({
total_registrations: yupNumber().integer().defined(),
completed_claims: yupNumber().integer().defined(),
new_user_signups: yupNumber().integer().defined(),
api_keys_issued: yupNumber().integer().defined(),
}).optional().default({
total_registrations: 0,
completed_claims: 0,
new_user_signups: 0,
api_keys_issued: 0,
}),
}).defined();
export const MetricsPaymentsOverviewSchema = yupObject({