From 49bac737671d5a8d22d367c9c5a31a45b88fd0d8 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 24 Aug 2026 17:12:18 -0400 Subject: [PATCH] feat(keys): session-bound PymtHouse API keys off Auth0 Derive ConsoleUser.id from hashed Auth0 sub, resolve the same id on the BFF from the session, and stop accepting client-supplied externalUserId. Shared pymthouse HTTP/route helpers land here so later billing PRs do not reintroduce the four copied env/M2M readers. Pins @pymthouse/builder-sdk to 0.6.5. --- .env.example | 10 + app/api/pymthouse/keys/exchange/route.ts | 190 ++++++ app/api/pymthouse/keys/route.ts | 71 +++ app/api/pymthouse/route-helpers.ts | 56 ++ components/console/AuthContext.tsx | 34 +- components/console/KeysView.tsx | 736 ++++++++++++----------- lib/console/external-user-id.test.ts | 16 + lib/console/external-user-id.ts | 20 + lib/console/pymthouse-bff.ts | 65 ++ lib/console/pymthouse-http.ts | 103 ++++ lib/console/pymthouse-keys-bff.ts | 119 ++++ lib/console/pymthouse-keys.ts | 9 + lib/console/read-response-json.ts | 11 + lib/console/session-user.ts | 30 + lib/console/useApiKeys.ts | 133 ++++ package.json | 2 + pnpm-lock.yaml | 19 + 17 files changed, 1272 insertions(+), 352 deletions(-) create mode 100644 app/api/pymthouse/keys/exchange/route.ts create mode 100644 app/api/pymthouse/keys/route.ts create mode 100644 app/api/pymthouse/route-helpers.ts create mode 100644 lib/console/external-user-id.test.ts create mode 100644 lib/console/external-user-id.ts create mode 100644 lib/console/pymthouse-bff.ts create mode 100644 lib/console/pymthouse-http.ts create mode 100644 lib/console/pymthouse-keys-bff.ts create mode 100644 lib/console/pymthouse-keys.ts create mode 100644 lib/console/read-response-json.ts create mode 100644 lib/console/session-user.ts create mode 100644 lib/console/useApiKeys.ts diff --git a/.env.example b/.env.example index 7064314..afec430 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,13 @@ APP_BASE_URL=http://localhost:3000 AUTH0_DOMAIN= AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= + +# PymtHouse (usage + API keys + signer sessions + plans/subscribe) +# Production: +PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc +# Local pymthouse (`npm run dev` HTTPS): https://localhost:3001/api/v1/oidc +PYMTHOUSE_PUBLIC_CLIENT_ID= +PYMTHOUSE_M2M_CLIENT_ID= +PYMTHOUSE_M2M_CLIENT_SECRET= +# Set to 1 for local http issuer only (not needed for https://localhost with mkcert) +PYMTHOUSE_ALLOW_INSECURE_HTTP= diff --git a/app/api/pymthouse/keys/exchange/route.ts b/app/api/pymthouse/keys/exchange/route.ts new file mode 100644 index 0000000..25a24bb --- /dev/null +++ b/app/api/pymthouse/keys/exchange/route.ts @@ -0,0 +1,190 @@ +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + normalizeDeviceExchangeResponse, + parseApiKeyExchangeRequestBody, +} from "@pymthouse/builder-sdk/signer/server"; +import { + pymthouseAppsOrigin, + readPymthouseM2mConfig, + readPublicClientId, +} from "@/lib/console/pymthouse-http"; + +const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; +const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + +type ExchangeConfig = { + issuerUrl: string; + publicClientId: string; + m2mClientId: string; + m2mClientSecret: string; +}; + +/** Thin BFF; canonical issuer route is POST …/apps/{clientId}/oidc/token (RFC 8693). */ +function readApiKeyExchangeConfig(): ExchangeConfig | null { + const m2m = readPymthouseM2mConfig(); + try { + const publicClientId = readPublicClientId(); + const issuerUrl = + m2m?.issuerUrl ?? process.env.PYMTHOUSE_ISSUER_URL?.trim(); + if (!issuerUrl) return null; + return { + issuerUrl, + publicClientId, + m2mClientId: m2m?.m2mClientId ?? "", + m2mClientSecret: m2m?.m2mClientSecret ?? "", + }; + } catch { + return null; + } +} + +function readStringField( + body: Record, + key: string +): string | undefined { + const value = body[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function exchangeApiKeyViaOidcToken(input: { + config: ExchangeConfig; + apiKey: string; + scope?: string; +}): Promise { + const { config, apiKey, scope } = input; + const url = `${pymthouseAppsOrigin(config.issuerUrl)}/api/v1/apps/${encodeURIComponent(config.publicClientId)}/oidc/token`; + + const form = new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: apiKey, + subject_token_type: ACCESS_TOKEN_TYPE, + }); + if (scope) { + form.set("scope", scope); + } + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + if (config.m2mClientId && config.m2mClientSecret) { + const basic = Buffer.from( + [config.m2mClientId, config.m2mClientSecret].join(":") + ).toString("base64"); + headers.Authorization = `Basic ${basic}`; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: form.toString(), + cache: "no-store", + }); + + let parsed: Record; + try { + parsed = (await response.json()) as Record; + } catch { + throw new PmtHouseError("Token exchange returned invalid JSON", { + status: 502, + code: "invalid_exchange_response", + }); + } + + if (!response.ok) { + const description = + readStringField(parsed, "error_description") || + readStringField(parsed, "error") || + `Token exchange failed (${response.status})`; + throw new PmtHouseError(description, { + status: response.status, + code: readStringField(parsed, "error") ?? "api_key_exchange_failed", + }); + } + + const accessToken = readStringField(parsed, "access_token"); + if (!accessToken) { + throw new PmtHouseError("Token exchange response missing access_token", { + status: 502, + code: "invalid_exchange_response", + }); + } + + // signer_url comes from the issuer exchange response (app signer routing). + const signerUrl = readStringField(parsed, "signer_url"); + + const expiresIn = + typeof parsed.expires_in === "number" && Number.isFinite(parsed.expires_in) + ? parsed.expires_in + : 3600; + + const body = normalizeDeviceExchangeResponse( + { + access_token: accessToken, + expires_in: expiresIn, + scope: readStringField(parsed, "scope") || scope || "sign:job", + balanceUsdMicros: readStringField(parsed, "balanceUsdMicros") ?? "0", + lifetimeGrantedUsdMicros: + readStringField(parsed, "lifetimeGrantedUsdMicros") ?? "0", + }, + { signer_url: signerUrl } + ); + + return Response.json(body, { + status: 200, + headers: { "Cache-Control": "no-store" }, + }); +} + +function errorResponse(error: unknown): Response { + if (error instanceof PmtHouseError) { + return Response.json( + { + error: error.code ?? "api_key_exchange_failed", + error_description: error.message, + }, + { status: error.status ?? 500 } + ); + } + const message = + error instanceof Error ? error.message : "API key exchange failed"; + return Response.json( + { error: "api_key_exchange_failed", error_description: message }, + { status: 500 } + ); +} + +export async function POST(request: Request) { + const config = readApiKeyExchangeConfig(); + if (!config) { + return Response.json( + { + error: "server_misconfigured", + error_description: + "PYMTHOUSE_ISSUER_URL and PYMTHOUSE_PUBLIC_CLIENT_ID are required", + }, + { status: 503 } + ); + } + + try { + const parsed = await parseApiKeyExchangeRequestBody(request); + const effectiveClientId = parsed.clientId?.trim() || config.publicClientId; + if (effectiveClientId !== config.publicClientId) { + throw new PmtHouseError( + "clientId does not match configured public client", + { + status: 400, + code: "invalid_request", + } + ); + } + return await exchangeApiKeyViaOidcToken({ + config, + apiKey: parsed.apiKey, + scope: parsed.scope, + }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/pymthouse/keys/route.ts b/app/api/pymthouse/keys/route.ts new file mode 100644 index 0000000..43c4d14 --- /dev/null +++ b/app/api/pymthouse/keys/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createDashboardApiKey, + listDashboardApiKeys, + revokeDashboardApiKey, +} from "@/lib/console/pymthouse-keys-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const session = await requireConsoleSession(); + const keys = await listDashboardApiKeys( + session.externalUserId, + session.email + ); + return NextResponse.json({ keys }, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to list API keys"); + } +} + +export async function POST(request: NextRequest) { + let body: { label?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + try { + const session = await requireConsoleSession(); + const created = await createDashboardApiKey({ + externalUserId: session.externalUserId, + email: session.email, + label: body.label, + }); + return NextResponse.json(created, { + status: 201, + headers: PYMTHOUSE_NO_STORE_HEADERS, + }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to create API key"); + } +} + +export async function DELETE(request: NextRequest) { + const keyId = request.nextUrl.searchParams.get("keyId")?.trim(); + if (!keyId) { + return NextResponse.json({ error: "keyId is required" }, { status: 400 }); + } + + try { + const session = await requireConsoleSession(); + await revokeDashboardApiKey({ + externalUserId: session.externalUserId, + keyId, + }); + return NextResponse.json( + { success: true }, + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to revoke API key"); + } +} diff --git a/app/api/pymthouse/route-helpers.ts b/app/api/pymthouse/route-helpers.ts new file mode 100644 index 0000000..c001888 --- /dev/null +++ b/app/api/pymthouse/route-helpers.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { SessionRequiredError } from "@/lib/console/session-user"; + +export const PYMTHOUSE_NO_STORE_HEADERS = { + "Cache-Control": "no-store, max-age=0", +} as const; + +export function pymthouseErrorResponse( + error: unknown, + fallback: string +): NextResponse { + if (error instanceof SessionRequiredError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : fallback }, + { status: 502, headers: PYMTHOUSE_NO_STORE_HEADERS } + ); +} + +/** Https-preferring public origin for Stripe Checkout return URLs. */ +export function checkoutReturnOrigin(request: NextRequest): string { + const configuredOrigin = ( + process.env.DASHBOARD_PUBLIC_URL || + process.env.NEXT_PUBLIC_APP_URL || + process.env.APP_BASE_URL || + "" + ) + .trim() + .replace(/\/$/, ""); + let origin = configuredOrigin || request.nextUrl.origin; + try { + const parsed = new URL(origin); + if ( + parsed.protocol === "http:" && + parsed.hostname !== "localhost" && + parsed.hostname !== "127.0.0.1" + ) { + parsed.protocol = "https:"; + } + origin = parsed.origin; + } catch { + origin = request.nextUrl.origin; + } + return origin; +} diff --git a/components/console/AuthContext.tsx b/components/console/AuthContext.tsx index f1e86d5..059f845 100644 --- a/components/console/AuthContext.tsx +++ b/components/console/AuthContext.tsx @@ -4,14 +4,19 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, + useState, type ReactNode, } from "react"; import { useUser } from "@auth0/nextjs-auth0/client"; +import { externalUserIdFromSub } from "@/lib/console/external-user-id"; export type AuthProvider = "github" | "google" | "email"; export interface ConsoleUser { + /** PymtHouse externalUserId — `eu_`. */ + id: string; name: string; email: string; initials: string; @@ -59,23 +64,40 @@ function providerFromSub(sub?: string): AuthProvider { } export function AuthProvider({ children }: { children: ReactNode }) { - const { user: auth0User, isLoading } = useUser(); + const { user: auth0User, isLoading: auth0Loading } = useUser(); + const [externalUserId, setExternalUserId] = useState(null); + + useEffect(() => { + const sub = auth0User?.sub; + if (!sub) { + setExternalUserId(null); + return; + } + let cancelled = false; + void externalUserIdFromSub(sub).then((id) => { + if (!cancelled) setExternalUserId(id); + }); + return () => { + cancelled = true; + }; + }, [auth0User?.sub]); const user = useMemo(() => { - if (!auth0User) return null; + if (!auth0User || !externalUserId) return null; const email = auth0User.email?.trim() || ""; const name = displayNameFrom( email, - auth0User.name?.trim() || auth0User.nickname?.trim(), + auth0User.name?.trim() || auth0User.nickname?.trim() ); return { + id: externalUserId, name, email, initials: getInitials(name) || "U", provider: providerFromSub(auth0User.sub), avatarUrl: auth0User.picture, }; - }, [auth0User]); + }, [auth0User, externalUserId]); const disconnect = useCallback(() => { window.location.assign("/auth/logout"); @@ -84,8 +106,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { return ( = { - restricted: { - label: "Restricted", - desc: "Run apps, read activity and usage", - scopes: "/v1/inference, /v1/runs, /v1/usage", - color: "#25ABD0", - }, - admin: { - label: "Admin", - desc: "Everything Restricted can do, plus manage keys, billing, and members", - scopes: "/v1/*", - color: "#f59e0b", - }, -}; - -const ROTATE_DAYS_THRESHOLD = 90; - -// Mock data — mirrors the design's seed; keep it close so visual review tracks. -const KEYS: KeyRow[] = [ - { - id: "k_01", - environmentId: "env-production", - name: "Production · web", - prefix: "lp_live_x8k2", - suffix: "m9p3", - scope: "restricted", - lastUsed: "12s ago", - lastUsedHost: "us-west-2 · AWS", - lastUsedIp: "54.218.31.4", - runs7d: 14_820, - created: "Mar 14, 2025", - createdBy: { name: "Zain", initials: "ZM", color: "var(--color-green)" }, - daysSinceRotation: 49, - }, - { - id: "k_02", - environmentId: "env-production", - name: "iOS app · TestFlight", - prefix: "lp_live_q4n7", - suffix: "b1y8", - scope: "restricted", - lastUsed: "3m ago", - lastUsedHost: "iOS device", - lastUsedIp: "17.110.40.221", - runs7d: 2_418, - created: "Mar 30, 2025", - createdBy: { name: "Zain", initials: "ZM", color: "var(--color-green)" }, - daysSinceRotation: 33, - }, - { - id: "k_03", - environmentId: "env-development", - name: "CI · GitHub Actions", - prefix: "lp_test_a3f1", - suffix: "d7c2", - scope: "admin", - lastUsed: "4 days ago", - lastUsedHost: "GitHub Actions", - lastUsedIp: "140.82.115.10", - runs7d: 136, - created: "Feb 02, 2025", - createdBy: { name: "Maya", initials: "MK", color: "#7c3aed" }, - daysSinceRotation: 91, - }, - { - id: "k_04", - environmentId: "env-development", - name: "Local dev · laptop", - prefix: "lp_test_k2v9", - suffix: "p4r1", - scope: "restricted", - lastUsed: "1h ago", - lastUsedHost: "localhost", - lastUsedIp: "127.0.0.1", - runs7d: 482, - created: "Apr 18, 2025", - createdBy: { name: "Maya", initials: "MK", color: "#7c3aed" }, - daysSinceRotation: 11, - }, -]; - -// ── Sub-components ────────────────────────────────────────────────────────── - -function ScopeBadge({ scope }: { scope: Scope }) { - const s = SCOPES[scope]; - return ( - - - ); -} - -function CopyField({ value }: { value: string }) { +function CopyField({ + value, + wrap = false, +}: { + value: string; + /** Multi-line wrap for long base64 python-gateway tokens. */ + wrap?: boolean; +}) { const [copied, setCopied] = useState(false); const onCopy = (e: React.MouseEvent) => { e.stopPropagation(); @@ -163,20 +41,24 @@ function CopyField({ value }: { value: string }) { type="button" onClick={onCopy} title="Copy" - className={`flex w-full items-center gap-2.5 rounded-[4px] border bg-dark px-3 py-2.5 text-left transition-colors ${ + className={`flex w-full items-start gap-2.5 rounded-[4px] border bg-dark px-3 py-2.5 text-left transition-colors ${ copied ? "border-green-bright bg-green/15" : "border-subtle hover:border-green hover:bg-dark-card" }`} > {value}