mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat(agent-auth): add AuthMD agentic-registration app
Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
105f03fb94
commit
5fb4a93f2c
@ -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;
|
||||
@ -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/);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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"],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
256
apps/backend/src/lib/agent-auth-registration.ts
Normal file
256
apps/backend/src/lib/agent-auth-registration.ts
Normal 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;
|
||||
}
|
||||
271
apps/backend/src/lib/agent-auth.ts
Normal file
271
apps/backend/src/lib/agent-auth.ts
Normal 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");
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Claim Agent Auth Registration",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
462
apps/e2e/tests/backend/endpoints/api/v1/agent-auth.test.ts
Normal file
462
apps/e2e/tests/backend/endpoints/api/v1/agent-auth.test.ts
Normal 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),
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
604
examples/demo/agent-auth-demo.ts
Normal file
604
examples/demo/agent-auth-demo.ts
Normal 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;
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -26,6 +26,12 @@ const branchSchemaFuzzerConfig = [{
|
||||
user: [true, false],
|
||||
}],
|
||||
}],
|
||||
agentAuth: [{
|
||||
identityTypes: [{
|
||||
serviceAuth: [true, false],
|
||||
anonymous: [true, false],
|
||||
}],
|
||||
}],
|
||||
auth: [{
|
||||
allowSignUp: [true, false],
|
||||
password: [{
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user