From fcb6d314156159e67df25cd7176cbcdf8bc34752 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 26 Aug 2026 14:49:56 -0400 Subject: [PATCH 1/6] feat(mcp): add identity code handling and mint subject resolution - Introduced new API route for redeeming identity codes, allowing for secure user authentication. - Enhanced existing minting logic to utilize resolved subjects from identity codes. - Updated OAuth login bridge functions to support issuing and redeeming identity codes. - Added tests for identity code functionality and mint subject resolution to ensure reliability. --- app/api/internal/mcp/mint/route.ts | 23 ++-- app/api/v1/auth/mcp/complete/route.ts | 7 ++ app/api/v1/auth/mcp/identity/route.ts | 36 ++++++ lib/console/mcp-oauth-login-bridge.test.ts | 54 +++++++- lib/console/mcp-oauth-login-bridge.ts | 139 +++++++++++++++++++-- 5 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 app/api/v1/auth/mcp/identity/route.ts diff --git a/app/api/internal/mcp/mint/route.ts b/app/api/internal/mcp/mint/route.ts index 83ef05c..c46e3b2 100644 --- a/app/api/internal/mcp/mint/route.ts +++ b/app/api/internal/mcp/mint/route.ts @@ -7,6 +7,7 @@ import { mintMcpCompositeKey, mintRouteConfigured, } from "@/lib/console/mcp-internal-mint"; +import { resolveMcpMintSubject } from "@/lib/console/mcp-oauth-login-bridge"; export const runtime = "nodejs"; @@ -36,26 +37,30 @@ export async function POST(request: NextRequest) { return json(503, mismatch); } - let body: { externalUserId?: unknown; email?: unknown; label?: unknown }; + let body: { + code?: unknown; + externalUserId?: unknown; + email?: unknown; + label?: unknown; + }; try { body = (await request.json()) as typeof body; } catch { return json(400, { error: "invalid_request", error_description: "invalid_json" }); } - const externalUserId = - typeof body.externalUserId === "string" ? body.externalUserId.trim() : ""; - if (!externalUserId || externalUserId.length > 256) { - return json(400, { - error: "invalid_request", - error_description: "externalUserId is required", + const subject = resolveMcpMintSubject(body); + if (!subject.ok) { + return json(subject.status, { + error: subject.error, + error_description: subject.error_description, }); } try { const minted = await mintMcpCompositeKey({ - externalUserId, - email: typeof body.email === "string" ? body.email.trim() : undefined, + externalUserId: subject.externalUserId, + email: subject.email, label: typeof body.label === "string" ? body.label : undefined, }); return json(200, { apiKey: minted.apiKey }); diff --git a/app/api/v1/auth/mcp/complete/route.ts b/app/api/v1/auth/mcp/complete/route.ts index e566fac..d1ac790 100644 --- a/app/api/v1/auth/mcp/complete/route.ts +++ b/app/api/v1/auth/mcp/complete/route.ts @@ -6,6 +6,7 @@ import { externalUserIdFromSub } from "@/lib/console/external-user-id"; import { buildMcpOauthCallbackUrl, decodeMcpOauthPendingCookie, + issueMcpIdentityCode, MCP_OAUTH_PENDING_COOKIE, } from "@/lib/console/mcp-oauth-login-bridge"; @@ -36,11 +37,17 @@ export async function GET(request: NextRequest) { const externalUserId = await externalUserIdFromSub(sub); const email = session.user.email?.trim(); + const code = issueMcpIdentityCode({ + externalUserId, + email: email || undefined, + state: pending.state, + }); const target = buildMcpOauthCallbackUrl({ redirectUri: pending.redirectUri, state: pending.state, externalUserId, email: email || undefined, + code, }); return NextResponse.redirect(target); } diff --git a/app/api/v1/auth/mcp/identity/route.ts b/app/api/v1/auth/mcp/identity/route.ts new file mode 100644 index 0000000..c73b94e --- /dev/null +++ b/app/api/v1/auth/mcp/identity/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { redeemMcpIdentityCode } from "@/lib/console/mcp-oauth-login-bridge"; + +export const runtime = "nodejs"; + +function json(status: number, body: Record) { + return NextResponse.json(body, { + status, + headers: { "Cache-Control": "no-store" }, + }); +} + +export async function POST(request: NextRequest) { + let body: { code?: unknown }; + try { + body = (await request.json()) as typeof body; + } catch { + return json(400, { error: "invalid_request", error_description: "invalid_json" }); + } + + const code = typeof body.code === "string" ? body.code.trim() : ""; + if (!code) { + return json(400, { error: "invalid_request", error_description: "code is required" }); + } + + const grant = redeemMcpIdentityCode(code); + if (!grant) { + return json(401, { error: "unauthorized", error_description: "invalid or expired code" }); + } + + return json(200, { + externalUserId: grant.externalUserId, + ...(grant.email ? { email: grant.email } : {}), + }); +} diff --git a/lib/console/mcp-oauth-login-bridge.test.ts b/lib/console/mcp-oauth-login-bridge.test.ts index 9587cf5..65d11e9 100644 --- a/lib/console/mcp-oauth-login-bridge.test.ts +++ b/lib/console/mcp-oauth-login-bridge.test.ts @@ -5,8 +5,11 @@ import { buildMcpOauthCallbackUrl, decodeMcpOauthPendingCookie, encodeMcpOauthPendingCookie, + issueMcpIdentityCode, isAllowedMcpRedirectUri, parseMcpOauthLoginQuery, + redeemMcpIdentityCode, + resolveMcpMintSubject, } from "./mcp-oauth-login-bridge.ts"; const CALLBACK = "https://agent.livepeer.org/api/mcp/oauth/callback"; @@ -60,15 +63,64 @@ test("pending cookie round-trips and rejects tampering", () => { assert.equal(decodeMcpOauthPendingCookie(`${encoded}x`), null); }); -test("buildMcpOauthCallbackUrl echoes state and subject", () => { +test("buildMcpOauthCallbackUrl echoes state, hashed subject, and code", () => { const url = buildMcpOauthCallbackUrl({ redirectUri: CALLBACK, state: "st-1", externalUserId: "eu_abc", email: "user@example.com", + code: "mcp_id_test", }); const parsed = new URL(url); assert.equal(parsed.searchParams.get("state"), "st-1"); assert.equal(parsed.searchParams.get("external_user_id"), "eu_abc"); + assert.equal(parsed.searchParams.get("code"), "mcp_id_test"); assert.equal(parsed.searchParams.get("email"), "user@example.com"); }); + +test("identity code binds mint to login; free-form id is not a subject", () => { + process.env.MCP_OAUTH_BRIDGE_SECRET = "test-bridge-secret"; + const code = issueMcpIdentityCode({ + externalUserId: "eu_from_login", + email: "user@example.com", + state: "st-1", + }); + assert.deepEqual(redeemMcpIdentityCode(code), { + externalUserId: "eu_from_login", + email: "user@example.com", + state: "st-1", + }); + assert.equal(redeemMcpIdentityCode(`${code}x`), null); + assert.deepEqual(resolveMcpMintSubject({}), { + ok: false, + status: 400, + error: "invalid_request", + error_description: "code is required", + }); + assert.deepEqual(resolveMcpMintSubject({ externalUserId: "eu_from_login" }), { + ok: false, + status: 400, + error: "invalid_request", + error_description: "code is required", + }); + assert.deepEqual(resolveMcpMintSubject({ code: "mcp_id_bogus" }), { + ok: false, + status: 401, + error: "unauthorized", + error_description: "invalid or expired code", + }); + assert.deepEqual( + resolveMcpMintSubject({ code, externalUserId: "eu_someone_else" }), + { + ok: false, + status: 400, + error: "invalid_request", + error_description: "externalUserId does not match code", + } + ); + assert.deepEqual(resolveMcpMintSubject({ code }), { + ok: true, + externalUserId: "eu_from_login", + email: "user@example.com", + }); +}); diff --git a/lib/console/mcp-oauth-login-bridge.ts b/lib/console/mcp-oauth-login-bridge.ts index 35c5d40..5d1af9e 100644 --- a/lib/console/mcp-oauth-login-bridge.ts +++ b/lib/console/mcp-oauth-login-bridge.ts @@ -57,10 +57,6 @@ export function parseMcpOauthLoginQuery(input: { } export function encodeMcpOauthPendingCookie(pending: McpOauthPending): string { - const secret = bridgeSecret(); - if (!secret) { - throw new Error("MCP OAuth bridge secret is not configured"); - } const payload = Buffer.from( JSON.stringify({ ...pending, @@ -68,14 +64,57 @@ export function encodeMcpOauthPendingCookie(pending: McpOauthPending): string { }), "utf8" ).toString("base64url"); - const sig = createHmac("sha256", secret).update(payload).digest("base64url"); - return `${payload}.${sig}`; + return signPayload(payload); } export function decodeMcpOauthPendingCookie( value: string | undefined ): McpOauthPending | null { if (!value) return null; + const payload = verifySignedPayload(value); + if (!payload) return null; + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + state?: unknown; + redirectUri?: unknown; + exp?: unknown; + }; + if ( + typeof parsed.state !== "string" || + typeof parsed.redirectUri !== "string" || + typeof parsed.exp !== "number" || + parsed.exp < Date.now() + ) { + return null; + } + if (!isAllowedMcpRedirectUri(parsed.redirectUri)) { + return null; + } + return { state: parsed.state, redirectUri: parsed.redirectUri }; + } catch { + return null; + } +} + +export type McpIdentityGrant = { + externalUserId: string; + email?: string; + state: string; +}; + +const IDENTITY_TTL_MS = PENDING_TTL_MS; +export const MCP_IDENTITY_CODE_PREFIX = "mcp_id_"; + +function signPayload(payload: string): string { + const secret = bridgeSecret(); + if (!secret) { + throw new Error("MCP OAuth bridge secret is not configured"); + } + const sig = createHmac("sha256", secret).update(payload).digest("base64url"); + return `${payload}.${sig}`; +} + +function verifySignedPayload(value: string): string | null { const secret = bridgeSecret(); if (!secret) return null; const [payload, sig] = value.split("."); @@ -86,38 +125,114 @@ export function decodeMcpOauthPendingCookie( if (left.length !== right.length || !timingSafeEqual(left, right)) { return null; } + return payload; +} + +export function issueMcpIdentityCode(grant: McpIdentityGrant): string { + const payload = Buffer.from( + JSON.stringify({ + eu: grant.externalUserId, + email: grant.email, + state: grant.state, + exp: Date.now() + IDENTITY_TTL_MS, + }), + "utf8" + ).toString("base64url"); + return `${MCP_IDENTITY_CODE_PREFIX}${signPayload(payload)}`; +} + +export function redeemMcpIdentityCode(code: string | undefined): McpIdentityGrant | null { + const trimmed = code?.trim() ?? ""; + if (!trimmed.startsWith(MCP_IDENTITY_CODE_PREFIX)) return null; + const signed = trimmed.slice(MCP_IDENTITY_CODE_PREFIX.length); + const payload = verifySignedPayload(signed); + if (!payload) return null; try { const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + eu?: unknown; + email?: unknown; state?: unknown; - redirectUri?: unknown; exp?: unknown; }; if ( + typeof parsed.eu !== "string" || + !parsed.eu || + parsed.eu.length > 256 || typeof parsed.state !== "string" || - typeof parsed.redirectUri !== "string" || typeof parsed.exp !== "number" || parsed.exp < Date.now() ) { return null; } - if (!isAllowedMcpRedirectUri(parsed.redirectUri)) { - return null; - } - return { state: parsed.state, redirectUri: parsed.redirectUri }; + const email = + typeof parsed.email === "string" && parsed.email.trim() + ? parsed.email.trim() + : undefined; + return { externalUserId: parsed.eu, email, state: parsed.state }; } catch { return null; } } +export function resolveMcpMintSubject(body: { + code?: unknown; + externalUserId?: unknown; + email?: unknown; +}): + | { ok: true; externalUserId: string; email?: string } + | { ok: false; status: 400 | 401; error: string; error_description: string } { + const code = typeof body.code === "string" ? body.code.trim() : ""; + if (!code) { + return { + ok: false, + status: 400, + error: "invalid_request", + error_description: "code is required", + }; + } + const grant = redeemMcpIdentityCode(code); + if (!grant) { + return { + ok: false, + status: 401, + error: "unauthorized", + error_description: "invalid or expired code", + }; + } + const claimed = + typeof body.externalUserId === "string" ? body.externalUserId.trim() : ""; + if (claimed && claimed !== grant.externalUserId) { + return { + ok: false, + status: 400, + error: "invalid_request", + error_description: "externalUserId does not match code", + }; + } + const emailOverride = + typeof body.email === "string" && body.email.trim() + ? body.email.trim() + : undefined; + return { + ok: true, + externalUserId: grant.externalUserId, + email: emailOverride ?? grant.email, + }; +} + export function buildMcpOauthCallbackUrl(input: { redirectUri: string; state: string; externalUserId: string; email?: string; + code?: string; }): string { const url = new URL(input.redirectUri); url.searchParams.set("state", input.state); url.searchParams.set("external_user_id", input.externalUserId); + if (input.code) { + url.searchParams.set("code", input.code); + } if (input.email) { url.searchParams.set("email", input.email); } From 24e18f20c49c1bb6b5cd264db38e3c0ffa3d70e7 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 26 Aug 2026 17:12:01 -0400 Subject: [PATCH 2/6] feat(env): update .env.example for local development and add Auth0 consent bootstrap script - Enhanced `.env.example` with local Storyboard URLs for easier development. - Introduced `auth0-consent-bootstrap.sh` script to automate Auth0 tenant setup for user consent screens, ensuring proper API configuration and scope management. - Updated tests to accommodate new environment variable handling for local setups. --- .env.example | 3 + lib/console/mcp-internal-mint.test.ts | 7 + lib/console/mcp-internal-mint.ts | 11 ++ scripts/auth0-consent-bootstrap.sh | 225 ++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100755 scripts/auth0-consent-bootstrap.sh diff --git a/.env.example b/.env.example index d9a83d2..df20aeb 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,11 @@ RUNNER_DISCOVERY_URL=http://localhost:8935/discovery # Agent MCP SSO + mint (Scenario A). Unset secret/allowlist → mint route 404. # MCP_INTERNAL_MINT_SECRET= # MCP_INTERNAL_MINT_ALLOWLIST=https://agent.livepeer.org +# Local Storyboard (`npx next dev -p 3002`): http://localhost:3002 +# MCP_INTERNAL_MINT_ALLOWLIST=http://localhost:3002 # Optional explicit callback URLs; else {allowlist origin}/api/mcp/oauth/callback # MCP_OAUTH_REDIRECT_ALLOWLIST=https://agent.livepeer.org/api/mcp/oauth/callback +# MCP_OAUTH_REDIRECT_ALLOWLIST=http://localhost:3002/api/mcp/oauth/callback # MCP_OAUTH_BRIDGE_SECRET= # Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660 diff --git a/lib/console/mcp-internal-mint.test.ts b/lib/console/mcp-internal-mint.test.ts index 9f13481..36ebbcd 100644 --- a/lib/console/mcp-internal-mint.test.ts +++ b/lib/console/mcp-internal-mint.test.ts @@ -48,11 +48,18 @@ test("authorizeMcpMint fail-closed matrix", () => { test("billingAppMismatch pins RS-2 in non-prod", () => { const prev = process.env.VERCEL_ENV; + const prevBase = process.env.APP_BASE_URL; process.env.VERCEL_ENV = "preview"; + delete process.env.APP_BASE_URL; process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = "app_deadbeefdeadbeefdeadbeef"; assert.equal(billingAppMismatch()?.error, "billing_app_mismatch"); process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = RS2_TEST_BILLING_APP_ID; assert.equal(billingAppMismatch(), null); + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = "app_deadbeefdeadbeefdeadbeef"; + process.env.APP_BASE_URL = "http://localhost:3000"; + assert.equal(billingAppMismatch(), null); if (prev === undefined) delete process.env.VERCEL_ENV; else process.env.VERCEL_ENV = prev; + if (prevBase === undefined) delete process.env.APP_BASE_URL; + else process.env.APP_BASE_URL = prevBase; }); diff --git a/lib/console/mcp-internal-mint.ts b/lib/console/mcp-internal-mint.ts index 8978ea4..0df1e4e 100644 --- a/lib/console/mcp-internal-mint.ts +++ b/lib/console/mcp-internal-mint.ts @@ -51,6 +51,17 @@ export function billingAppMismatch(): { error: string; error_description: string if (publicClientId === RS2_TEST_BILLING_APP_ID) { return null; } + const base = process.env.APP_BASE_URL?.trim() ?? ""; + let local = false; + try { + const host = new URL(base).hostname; + local = host === "localhost" || host === "127.0.0.1"; + } catch { + local = false; + } + if (local) { + return null; + } return { error: "billing_app_mismatch", error_description: `Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=${RS2_TEST_BILLING_APP_ID}`, diff --git a/scripts/auth0-consent-bootstrap.sh b/scripts/auth0-consent-bootstrap.sh new file mode 100755 index 0000000..78472a4 --- /dev/null +++ b/scripts/auth0-consent-bootstrap.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# +# Bootstrap the Auth0 tenant so Auth0 renders the end-user consent screen for +# pymthouse-backed capabilities, while pymthouse keeps minting via Builder M2M. +# +# What it does (idempotent): +# 1. Upserts an Auth0 Resource Server (custom API) whose scope descriptions are +# copied verbatim from pymthouse src/lib/oidc/scopes.ts, so the consent copy +# matches the screen it replaces. +# 2. Turns OFF consent-skipping for first-party clients on that API, which is +# what actually makes the prompt appear. +# 3. Optionally creates a dedicated application, only if you want the MCP +# bridge isolated from Console's dashboard login client. +# +# Consent only triggers when a client requests this API as `audience`. Console's +# normal dashboard login passes no audience, so it stays prompt-free without +# needing a separate client. +# +# Usage: +# DRY_RUN=1 ./scripts/auth0-consent-bootstrap.sh # print planned calls +# ./scripts/auth0-consent-bootstrap.sh # apply +# +# Requires: auth0 CLI (>=1.32), jq, and `auth0 login` against the target tenant. + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── + +: "${AUTH0_DOMAIN:=}" +: "${API_IDENTIFIER:=https://api.livepeer.org/pymthouse}" +: "${API_NAME:=Livepeer Media (pymthouse)}" +: "${TOKEN_LIFETIME:=3600}" +: "${DRY_RUN:=0}" + +# Set CREATE_APP=1 only if you want a dedicated Auth0 app for the MCP bridge +# instead of reusing Console's AUTH0_CLIENT_ID. +: "${CREATE_APP:=0}" +: "${APP_NAME:=Livepeer Agent MCP}" +: "${APP_CALLBACKS:=http://localhost:3000/auth/callback,https://console.livepeer.org/auth/callback}" + +# Consentable scopes ONLY. +# +# The live staging discovery document advertises: +# openid email profile sign:job users:read users:write users:token +# device:approve admin +# +# Deliberately excluded here: +# - openid / email / profile native Auth0 OIDC scopes, not custom-API scopes +# - users:* / device:approve / admin server-side Builder M2M scopes. These are +# never user-consented, and pymthouse's assertSignJobNotMixedWithAdmin() +# rejects any token mixing them with sign:job. +# +# Keep descriptions in sync with pymthouse src/lib/oidc/scopes.ts — Auth0 renders +# `description` as the consent line item. +read -r -d '' SCOPES_JSON <<'JSON' || true +[ + { + "value": "sign:job", + "description": "Access all remote signer endpoints, including discovery and payment signing" + } +] +JSON + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +log() { printf '\033[1;34m>\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32mOK\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mX\033[0m %s\n' "$*" >&2; exit 1; } + +# Run an auth0 CLI call, or print it under DRY_RUN. +a0() { + if [[ "$DRY_RUN" == "1" ]]; then + printf '\033[2m would run: auth0 %s\033[0m\n' "$*" >&2 + echo '{}' + return 0 + fi + auth0 "$@" +} + +# ── Preflight ───────────────────────────────────────────────────────────────── + +command -v auth0 >/dev/null 2>&1 || die "auth0 CLI not found. brew install auth0/auth0-cli/auth0" +command -v jq >/dev/null 2>&1 || die "jq not found." + +active_tenant="$(auth0 tenants list --json 2>/dev/null \ + | jq -r '(.[] | select(.active == true) | .name) // empty' || true)" + +if [[ -z "$active_tenant" ]]; then + # Older CLI builds omit `active` in --json; fall back to the marked row. + active_tenant="$(auth0 tenants list 2>/dev/null | awk '/→/ {print $2}' | head -1)" +fi + +[[ -n "$active_tenant" ]] || die "No active Auth0 tenant. Run: auth0 login" + +if [[ -n "$AUTH0_DOMAIN" && "$AUTH0_DOMAIN" != "$active_tenant" ]]; then + die "Active tenant is '$active_tenant' but AUTH0_DOMAIN='$AUTH0_DOMAIN'. + Switch with: auth0 tenants use $AUTH0_DOMAIN" +fi + +log "Tenant: $active_tenant" +log "API: $API_NAME <$API_IDENTIFIER>" +log "Scopes: $(echo "$SCOPES_JSON" | jq -r '[.[].value] | join(", ")')" +[[ "$DRY_RUN" == "1" ]] && warn "DRY_RUN=1 — no changes will be made." + +# ── 1. Upsert the Resource Server (custom API) ──────────────────────────────── + +log "Listing resource servers (also verifies the CLI session)..." + +# `auth0 tenants list` reads local config and succeeds even when the stored +# credential is expired, so it cannot be used as an auth check. `auth0 api` +# prompts interactively to re-authorize, which would hang a script — redirect +# stdin from /dev/null and cap it with timeout so it fails fast instead. +api_list_raw="$(timeout 30 auth0 api get "resource-servers" /dev/null || true)" + +if ! echo "$api_list_raw" | jq -e 'type == "array"' >/dev/null 2>&1; then + die "Could not list resource servers for '$active_tenant'. + The usual cause is an expired CLI session. Re-authorize with: + auth0 login + and complete the browser confirmation (a declined prompt leaves the old + session in place). Then re-run this script." +fi + +existing_api="$(echo "$api_list_raw" \ + | jq -c --arg id "$API_IDENTIFIER" 'map(select(.identifier == $id)) | first // empty')" + +# skip_consent_for_verifiable_first_party_clients=false is the switch that makes +# Auth0 show the consent prompt to first-party clients requesting this audience. +api_payload="$(jq -n \ + --arg name "$API_NAME" \ + --arg identifier "$API_IDENTIFIER" \ + --argjson scopes "$SCOPES_JSON" \ + --argjson lifetime "$TOKEN_LIFETIME" \ + '{ + name: $name, + identifier: $identifier, + scopes: $scopes, + signing_alg: "RS256", + token_lifetime: $lifetime, + allow_offline_access: false, + skip_consent_for_verifiable_first_party_clients: false, + enforce_policies: false + }')" + +if [[ -n "$existing_api" ]]; then + api_id="$(echo "$existing_api" | jq -r '.id')" + ok "Found existing API (id: $api_id) — patching scopes + consent flag." + # identifier is immutable on update; strip it. + patch_payload="$(echo "$api_payload" | jq 'del(.identifier)')" + a0 api patch "resource-servers/${api_id}" --data "$patch_payload" >/dev/null + ok "Patched resource server." +else + log "Creating resource server..." + created="$(a0 api post "resource-servers" --data "$api_payload")" + api_id="$(echo "$created" | jq -r '.id // "dry-run"')" + ok "Created resource server (id: $api_id)." +fi + +# ── 2. Optional dedicated application ───────────────────────────────────────── + +mcp_client_id="" +if [[ "$CREATE_APP" == "1" ]]; then + log "Looking up existing application '$APP_NAME'..." + existing_app="$(auth0 apps list --json 2>/dev/null \ + | jq -c --arg n "$APP_NAME" 'map(select(.name == $n)) | first // empty' || true)" + + if [[ -n "$existing_app" ]]; then + mcp_client_id="$(echo "$existing_app" | jq -r '.client_id')" + ok "Reusing application (client_id: $mcp_client_id)." + else + log "Creating regular web application..." + created_app="$(a0 apps create \ + --name "$APP_NAME" \ + --type regular \ + --auth-method "Post" \ + --callbacks "$APP_CALLBACKS" \ + --logout-urls "$APP_CALLBACKS" \ + --json)" + mcp_client_id="$(echo "$created_app" | jq -r '.client_id // "dry-run"')" + ok "Created application (client_id: $mcp_client_id)." + warn "Retrieve the secret with: auth0 apps show $mcp_client_id -r --json | jq -r .client_secret" + warn "Never commit it. Put it in Console's .env only." + fi +else + log "CREATE_APP=0 — reusing Console's existing AUTH0_CLIENT_ID." +fi + +# ── 3. Report ───────────────────────────────────────────────────────────────── + +echo +ok "Done." +cat < +EOF +fi + +client_for_test="${mcp_client_id:-\$AUTH0_CLIENT_ID}" +cat < Date: Wed, 26 Aug 2026 19:25:47 -0400 Subject: [PATCH 3/6] feat(auth): integrate Auth0 login flow and enhance MCP functionality - Added new API route for MCP Auth0 login, facilitating user authentication with consent prompts. - Updated `.env.example` to include Auth0 audience and scopes for local development. - Enhanced login page to support dynamic login URLs based on MCP context. - Introduced tests for MCP Auth0 login parameters and authorization handling. - Removed deprecated billing app mismatch logic from minting process. --- .env.example | 4 +- app/api/internal/mcp/mint/route.ts | 6 -- app/api/v1/auth/mcp/login/route.ts | 38 +++++++++++ components/console/LoginPage.tsx | 12 ++-- lib/console/mcp-auth0-login.test.ts | 48 ++++++++++++++ lib/console/mcp-auth0-login.ts | 35 ++++++++++ lib/console/mcp-internal-mint.test.ts | 20 ------ lib/console/mcp-internal-mint.ts | 27 -------- package.json | 4 +- pnpm-lock.yaml | 3 + scripts/mcp-oidc-client.mjs | 92 +++++++++++++++++++++++++++ 11 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 app/api/v1/auth/mcp/login/route.ts create mode 100644 lib/console/mcp-auth0-login.test.ts create mode 100644 lib/console/mcp-auth0-login.ts create mode 100644 scripts/mcp-oidc-client.mjs diff --git a/.env.example b/.env.example index df20aeb..86f670b 100644 --- a/.env.example +++ b/.env.example @@ -43,5 +43,7 @@ RUNNER_DISCOVERY_URL=http://localhost:8935/discovery # MCP_OAUTH_REDIRECT_ALLOWLIST=https://agent.livepeer.org/api/mcp/oauth/callback # MCP_OAUTH_REDIRECT_ALLOWLIST=http://localhost:3002/api/mcp/oauth/callback # MCP_OAUTH_BRIDGE_SECRET= -# Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660 +# Auth0 Resource Server (consent). Only used on the MCP login hop. +# AUTH0_MCP_AUDIENCE=https://api.livepeer.org/pymthouse +# AUTH0_MCP_SCOPES=openid profile email sign:job diff --git a/app/api/internal/mcp/mint/route.ts b/app/api/internal/mcp/mint/route.ts index c46e3b2..0d6c3f9 100644 --- a/app/api/internal/mcp/mint/route.ts +++ b/app/api/internal/mcp/mint/route.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server"; import { PmtHouseError } from "@pymthouse/builder-sdk"; import { authorizeMcpMint, - billingAppMismatch, mintMcpCompositeKey, mintRouteConfigured, } from "@/lib/console/mcp-internal-mint"; @@ -32,11 +31,6 @@ export async function POST(request: NextRequest) { return json(auth.status, { error: auth.error }); } - const mismatch = billingAppMismatch(); - if (mismatch) { - return json(503, mismatch); - } - let body: { code?: unknown; externalUserId?: unknown; diff --git a/app/api/v1/auth/mcp/login/route.ts b/app/api/v1/auth/mcp/login/route.ts new file mode 100644 index 0000000..fc92985 --- /dev/null +++ b/app/api/v1/auth/mcp/login/route.ts @@ -0,0 +1,38 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; +import { mcpAuth0AuthorizationParameters } from "@/lib/console/mcp-auth0-login"; +import { + decodeMcpOauthPendingCookie, + MCP_OAUTH_COMPLETE_PATH, + MCP_OAUTH_PENDING_COOKIE, +} from "@/lib/console/mcp-oauth-login-bridge"; + +export const runtime = "nodejs"; + +/** + * Starts Auth0 via the official SDK (`startInteractiveLogin`). + * MCP-only: attaches AUTH0_MCP_AUDIENCE so Universal Login can prompt consent. + */ +export async function GET(request: NextRequest) { + const jar = await cookies(); + const pending = decodeMcpOauthPendingCookie( + jar.get(MCP_OAUTH_PENDING_COOKIE)?.value + ); + const origin = request.nextUrl.origin; + if (!pending) { + return NextResponse.redirect(new URL("/login", origin)); + } + + const connection = request.nextUrl.searchParams.get("connection")?.trim(); + const authorizationParameters = { + ...mcpAuth0AuthorizationParameters(), + ...(connection ? { connection } : {}), + }; + + return auth0.startInteractiveLogin({ + returnTo: MCP_OAUTH_COMPLETE_PATH, + authorizationParameters, + }); +} diff --git a/components/console/LoginPage.tsx b/components/console/LoginPage.tsx index 2cdd77d..f538c9c 100644 --- a/components/console/LoginPage.tsx +++ b/components/console/LoginPage.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { motion, AnimatePresence } from "framer-motion"; import { LivepeerWordmark } from "@/components/design-system/LivepeerLogo"; +import { isMcpAuth0ReturnTo, mcpAuth0LoginHref } from "@/lib/console/mcp-auth0-login"; function GoogleIcon({ className }: { className?: string }) { return ( @@ -48,12 +49,15 @@ export default function LoginPage({ }: LoginPageProps = {}) { const mode = initialMode; const encodedReturnTo = encodeURIComponent(returnTo); - const loginHref = - mode === "signup" + const mcpBridge = isMcpAuth0ReturnTo(returnTo); + const loginHref = mcpBridge + ? mcpAuth0LoginHref() + : mode === "signup" ? `/auth/login?screen_hint=signup&returnTo=${encodedReturnTo}` : `/auth/login?returnTo=${encodedReturnTo}`; - const googleHref = - mode === "signup" + const googleHref = mcpBridge + ? mcpAuth0LoginHref({ connection: "google-oauth2" }) + : mode === "signup" ? `/auth/login?screen_hint=signup&connection=google-oauth2&returnTo=${encodedReturnTo}` : `/auth/login?connection=google-oauth2&returnTo=${encodedReturnTo}`; diff --git a/lib/console/mcp-auth0-login.test.ts b/lib/console/mcp-auth0-login.test.ts new file mode 100644 index 0000000..dbdeaf1 --- /dev/null +++ b/lib/console/mcp-auth0-login.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + AUTH0_MCP_AUDIENCE_DEFAULT, + AUTH0_MCP_SCOPES_DEFAULT, + MCP_AUTH0_RETURN_TO, + isMcpAuth0ReturnTo, + mcpAuth0AuthorizationParameters, + mcpAuth0LoginHref, +} from "./mcp-auth0-login.ts"; +import { MCP_OAUTH_COMPLETE_PATH } from "./mcp-oauth-login-bridge.ts"; + +test("mcpAuth0AuthorizationParameters is empty without AUTH0_MCP_AUDIENCE", () => { + assert.deepEqual(mcpAuth0AuthorizationParameters({}), {}); + assert.deepEqual(mcpAuth0AuthorizationParameters({ AUTH0_MCP_AUDIENCE: " " }), {}); +}); + +test("mcpAuth0AuthorizationParameters requests the Auth0 API audience and consent", () => { + assert.deepEqual( + mcpAuth0AuthorizationParameters({ + AUTH0_MCP_AUDIENCE: AUTH0_MCP_AUDIENCE_DEFAULT, + }), + { + audience: AUTH0_MCP_AUDIENCE_DEFAULT, + scope: AUTH0_MCP_SCOPES_DEFAULT, + prompt: "consent", + } + ); + assert.deepEqual( + mcpAuth0AuthorizationParameters({ + AUTH0_MCP_AUDIENCE: AUTH0_MCP_AUDIENCE_DEFAULT, + AUTH0_MCP_SCOPES: "openid sign:job", + }).scope, + "openid sign:job" + ); +}); + +test("mcpAuth0LoginHref stays on the MCP Auth0 start route", () => { + assert.equal(mcpAuth0LoginHref(), "/api/v1/auth/mcp/login"); + assert.equal( + mcpAuth0LoginHref({ connection: "google-oauth2" }), + "/api/v1/auth/mcp/login?connection=google-oauth2" + ); + assert.equal(MCP_AUTH0_RETURN_TO, MCP_OAUTH_COMPLETE_PATH); + assert.equal(isMcpAuth0ReturnTo(MCP_OAUTH_COMPLETE_PATH), true); + assert.equal(isMcpAuth0ReturnTo("/home"), false); +}); diff --git a/lib/console/mcp-auth0-login.ts b/lib/console/mcp-auth0-login.ts new file mode 100644 index 0000000..03004b9 --- /dev/null +++ b/lib/console/mcp-auth0-login.ts @@ -0,0 +1,35 @@ +import type { AuthorizationParameters } from "@auth0/nextjs-auth0/types"; + +export const AUTH0_MCP_AUDIENCE_DEFAULT = "https://api.livepeer.org/pymthouse"; +export const AUTH0_MCP_SCOPES_DEFAULT = "openid profile email sign:job"; + +/** Must stay equal to MCP_OAUTH_COMPLETE_PATH in mcp-oauth-login-bridge.ts */ +export const MCP_AUTH0_RETURN_TO = "/api/v1/auth/mcp/complete"; + +/** Auth0 `/authorize` extras for the MCP login hop only — not dashboard login. */ +export function mcpAuth0AuthorizationParameters( + env: NodeJS.ProcessEnv = process.env +): AuthorizationParameters { + const audience = env.AUTH0_MCP_AUDIENCE?.trim(); + if (!audience) { + return {}; + } + return { + audience, + scope: env.AUTH0_MCP_SCOPES?.trim() || AUTH0_MCP_SCOPES_DEFAULT, + prompt: "consent", + }; +} + +export function isMcpAuth0ReturnTo(returnTo: string): boolean { + return returnTo === MCP_AUTH0_RETURN_TO; +} + +export function mcpAuth0LoginHref(input: { connection?: string } = {}): string { + const query = new URLSearchParams(); + if (input.connection) { + query.set("connection", input.connection); + } + const suffix = query.toString(); + return suffix ? `/api/v1/auth/mcp/login?${suffix}` : "/api/v1/auth/mcp/login"; +} diff --git a/lib/console/mcp-internal-mint.test.ts b/lib/console/mcp-internal-mint.test.ts index 36ebbcd..d58d008 100644 --- a/lib/console/mcp-internal-mint.test.ts +++ b/lib/console/mcp-internal-mint.test.ts @@ -3,9 +3,7 @@ import { test } from "node:test"; import { authorizeMcpMint, - billingAppMismatch, mintRouteConfigured, - RS2_TEST_BILLING_APP_ID, } from "./mcp-internal-mint.ts"; test("mintRouteConfigured requires secret and allowlist", () => { @@ -45,21 +43,3 @@ test("authorizeMcpMint fail-closed matrix", () => { { ok: true } ); }); - -test("billingAppMismatch pins RS-2 in non-prod", () => { - const prev = process.env.VERCEL_ENV; - const prevBase = process.env.APP_BASE_URL; - process.env.VERCEL_ENV = "preview"; - delete process.env.APP_BASE_URL; - process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = "app_deadbeefdeadbeefdeadbeef"; - assert.equal(billingAppMismatch()?.error, "billing_app_mismatch"); - process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = RS2_TEST_BILLING_APP_ID; - assert.equal(billingAppMismatch(), null); - process.env.PYMTHOUSE_PUBLIC_CLIENT_ID = "app_deadbeefdeadbeefdeadbeef"; - process.env.APP_BASE_URL = "http://localhost:3000"; - assert.equal(billingAppMismatch(), null); - if (prev === undefined) delete process.env.VERCEL_ENV; - else process.env.VERCEL_ENV = prev; - if (prevBase === undefined) delete process.env.APP_BASE_URL; - else process.env.APP_BASE_URL = prevBase; -}); diff --git a/lib/console/mcp-internal-mint.ts b/lib/console/mcp-internal-mint.ts index 0df1e4e..1c252fb 100644 --- a/lib/console/mcp-internal-mint.ts +++ b/lib/console/mcp-internal-mint.ts @@ -1,7 +1,5 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -export const RS2_TEST_BILLING_APP_ID = "app_98575870d7ae33589a3f0660"; - function parseMintAllowlist(raw: string | undefined): string[] { return (raw ?? "") .split(",") @@ -43,31 +41,6 @@ export function authorizeMcpMint(input: { return { ok: true }; } -export function billingAppMismatch(): { error: string; error_description: string } | null { - if (process.env.VERCEL_ENV === "production") { - return null; - } - const publicClientId = process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() ?? ""; - if (publicClientId === RS2_TEST_BILLING_APP_ID) { - return null; - } - const base = process.env.APP_BASE_URL?.trim() ?? ""; - let local = false; - try { - const host = new URL(base).hostname; - local = host === "localhost" || host === "127.0.0.1"; - } catch { - local = false; - } - if (local) { - return null; - } - return { - error: "billing_app_mismatch", - error_description: `Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=${RS2_TEST_BILLING_APP_ID}`, - }; -} - export async function mintMcpCompositeKey(input: { externalUserId: string; email?: string; diff --git a/package.json b/package.json index 4dfcc4a..7082ecc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit --incremental false", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "mcp:oidc-client": "node scripts/mcp-oidc-client.mjs" }, "dependencies": { "@auth0/nextjs-auth0": "^4.27.0", @@ -36,6 +37,7 @@ "eslint": "^9", "eslint-config-next": "^15.1.0", "eslint-config-prettier": "^10.1.8", + "openid-client": "^6.8.7", "postcss": "^8.5.0", "prettier": "^3.8.1", "tailwindcss": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ce89e..bdda409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + openid-client: + specifier: ^6.8.7 + version: 6.8.7 postcss: specifier: ^8.5.0 version: 8.5.8 diff --git a/scripts/mcp-oidc-client.mjs b/scripts/mcp-oidc-client.mjs new file mode 100644 index 0000000..29cda1c --- /dev/null +++ b/scripts/mcp-oidc-client.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * RFC 8414 + RFC 7636 + RFC 8252 public client against Agent's MCP AS. + * + * Uses `openid-client` (already a dependency of @auth0/nextjs-auth0) — the same + * certified stack Auth0's Next.js SDK uses — instead of a fixed :8765 harness. + * + * MCP_AS=http://localhost:3002 node scripts/mcp-oidc-client.mjs + * + * Binds an ephemeral loopback port, prints the authorize URL, then exchanges + * the code for mcp_at_*. Storyboard must allow `http://127.0.0.1:*`. + */ + +import http from "node:http"; +import * as client from "openid-client"; + +const issuer = new URL((process.env.MCP_AS ?? "http://localhost:3002").replace(/\/$/, "")); + +const server = http.createServer(); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); +const { port } = server.address(); +const redirectUri = `http://127.0.0.1:${port}/cb`; + +const config = await client.discovery( + issuer, + "livepeer-mcp-oidc-smoke", + { token_endpoint_auth_method: "none" }, + client.None(), + { + algorithm: "oauth2", + execute: [client.allowInsecureRequests], + } +); + +const codeVerifier = client.randomPKCECodeVerifier(); +const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); +const state = client.randomState(); + +const authorizeUrl = client.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + response_type: "code", + code_challenge: codeChallenge, + code_challenge_method: "S256", + state, +}); + +console.log("OIDC client (openid-client, OAuth 2.0 AS discovery)"); +console.log(` issuer: ${issuer.origin}`); +console.log(` redirect_uri: ${redirectUri}`); +console.log(` authorize:\n${authorizeUrl.href}\n`); + +const callbackUrl = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("timed out waiting for authorize redirect (5m)")); + }, 5 * 60 * 1000); + server.on("request", (req, res) => { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`); + if (url.pathname !== "/cb") { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + res.end("MCP OIDC client received the callback. You can close this tab."); + clearTimeout(timeout); + resolve(url); + }); +}); + +server.close(); + +const tokens = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier, + expectedState: state, + idTokenExpected: false, +}); + +const access = tokens.access_token; +if (!access) { + console.error("token response missing access_token:", tokens); + process.exit(1); +} +console.log("token_type:", tokens.token_type ?? "Bearer"); +console.log("expires_in:", tokens.expires_in ?? "(none)"); +console.log("access_token prefix:", `${access.slice(0, 12)}…`); +if (!access.startsWith("mcp_at_")) { + console.error("expected mcp_at_* access token"); + process.exit(1); +} From 470b738e6595fe98749c31fbf614e93581f6810a Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 26 Aug 2026 19:25:54 -0400 Subject: [PATCH 4/6] feat(device): implement device approval flow and enhance login integration - Added new DeviceApproveForm component for user device approval. - Created API route for device approval, handling user code and client ID validation. - Updated login page to support device return URLs, ensuring proper redirection after authentication. - Introduced tests for device approval logic and parameter validation. - Enhanced environment configuration in `.env.example` to reflect new client ID handling. --- .env.example | 5 + CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md | 311 ++++++++++++++++++++++++ CONSOLE-SSO-MINT-PARTNER-FOR-JOHN.md | 80 ++++++ app/(app)/device/DeviceApproveForm.tsx | 74 ++++++ app/(app)/device/page.tsx | 62 +++++ app/(auth)/login/page.tsx | 18 +- app/api/v1/auth/device/approve/route.ts | 38 +++ docs/SSO-MINT-OPERATOR.md | 8 +- lib/console/device-approval.test.ts | 62 +++++ lib/console/device-approval.ts | 42 ++++ lib/console/device-initiate.ts | 33 +++ 11 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md create mode 100644 CONSOLE-SSO-MINT-PARTNER-FOR-JOHN.md create mode 100644 app/(app)/device/DeviceApproveForm.tsx create mode 100644 app/(app)/device/page.tsx create mode 100644 app/api/v1/auth/device/approve/route.ts create mode 100644 lib/console/device-approval.test.ts create mode 100644 lib/console/device-approval.ts create mode 100644 lib/console/device-initiate.ts diff --git a/.env.example b/.env.example index 86f670b..ac004f3 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ AUTH0_CLIENT_SECRET= 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= +# Production Livepeer Agent app (device + mint): +# PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660 +# On that app, set initiateLoginUri to {APP_BASE_URL}/device and +# deviceThirdPartyInitiateLogin=true (PUT /api/v1/apps/{id}/settings). +# Public grantTypes must include urn:ietf:params:oauth:grant-type:device_code. PYMTHOUSE_M2M_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_SECRET= # Set to 1 for local http issuer only (not needed for https://localhost with mkcert) diff --git a/CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md b/CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md new file mode 100644 index 0000000..2e76ce2 --- /dev/null +++ b/CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md @@ -0,0 +1,311 @@ +# Agent brief: Livepeer Console — Scenario A SSO + mint partner + +**Target repo:** `livepeer/console` (Next.js App Router). +**Paste this brief into Claude/Codex working in Console.** Do not invent APIs; mirror the NaaP contract cited below. + +--- + +## 1. Role / mission + +Implement the **SSO + mint partner contract** in Livepeer Console so Livepeer Agent MCP (`livepeer/storyboard`, host `https://agent.livepeer.org`) can point `SSO_MINT_*` (or today’s `NAAP_MCP_*` fallbacks) at Console. User signs in via Console Auth0 → Agent callback receives a stable subject → Agent server-to-server mints a pymthouse composite via Console’s internal mint → Auth Resolution binds `mcp_at_*` → `create_media` uses composite `forwardBearer` on the existing signer / Live Runner billed tail (Scenario A). Console owns login bridge + mint only; **do not** build the MCP OAuth broker, `mcp_at_*` store, or LR dispatch in Console. + +--- + +## 2. Hard constraints + +| Rule | Action | +|------|--------| +| Contract fidelity | Match NaaP request/response/status semantics. Do not invent alternate mint paths, JWT-only mint, or broker routes. | +| Fail closed | Missing mint secret or empty caller allowlist → **404** (route not advertised). Wrong Bearer → **401**. Disallowed caller origin → **403**. Wrong/non-test billing app in non-prod → **503** `billing_app_mismatch`. | +| Secrets | `MCP_INTERNAL_MINT_SECRET`, `PYMTHOUSE_M2M_CLIENT_SECRET`, Auth0 secrets: **server-only**. Never log composites / secrets / `apiKey`. | +| RS-2 billing pin (non-prod) | Mint only when `PYMTHOUSE_PUBLIC_CLIENT_ID === app_98575870d7ae33589a3f0660`. Never default to a live customer app. | +| Redirect safety | Allowlist Storyboard callbacks only. Reject open redirects. Do **not** hardcode `returnTo=/home` on the MCP login path. | +| Already done | [console#14](https://github.com/livepeer/console/pull/14) Auth0 UI login — **do not redo**. Extend it for MCP params. | +| Wrong repo | Do **not** add Agent MCP broker / PRM / `mcp_at_*` / Auth Resolution / Scenario B pymthouse OIDC AS to Console. | + +--- + +## 3. Reference implementations (source of truth) + +**Workspace (NaaP):** `/Users/qiang.han/Documents/mycodespace/NaaP` +Console repo may be absent from this workspace — implement under Console’s Next.js App Router **equivalent** of the NaaP paths below. Prefer reading NaaP files over guessing Console layout. + +| Piece | NaaP source of truth | Notes | +|-------|----------------------|--------| +| Internal mint | `apps/web-next/src/app/api/internal/mcp/mint/route.ts` (+ `route.test.ts`) | Merged [naap#458](https://github.com/livepeer/naap/pull/458). Path: `POST /api/internal/mcp/mint`. | +| Builder mint helper | `apps/web-next/src/lib/pymthouse-keys-bff.ts` → `createPymthouseApiKey` | Upsert app user + `POST …/apps/{appId}/users/{externalUserId}/keys` with M2M Basic. | +| Login bridge lib | `apps/web-next/src/lib/mcp-oauth-login-bridge.ts` | Pending cookie, redirect allowlist, callback URL builder, optional identity code. | +| Login complete | `apps/web-next/src/app/api/v1/auth/mcp/complete/route.ts` | Authenticated redirect to Storyboard callback. | +| Optional identity | `apps/web-next/src/app/api/v1/auth/mcp/identity/route.ts` | `POST` `{ code }` → `{ externalUserId, email? }`. | +| Entry | NaaP `/login?mcp_oauth=1&state=…&redirect_uri=…` | Console: same query contract on `/login` (or documented authorize URL). | +| Design / plan | NaaP `MCP-OAUTH-PYMTHOUSE-DESIGN.html` Scenario A; Storyboard [PR #1192](https://github.com/livepeer/storyboard/pull/1192) §4 + §12 | Contract + Console gap matrix. | +| Auth0 baseline | [console#14](https://github.com/livepeer/console/pull/14) | Session + Auth0 `sub` as subject — already shipped. | + +**Product / hosts** + +| Name | Value | +|------|--------| +| Product (UI) | Livepeer Agent / Livepeer Console | +| MCP host | `https://agent.livepeer.org` | +| OAuth callback | `https://agent.livepeer.org/api/mcp/oauth/callback` | +| RS-2 test billing app | `app_98575870d7ae33589a3f0660` | + +Wire/repo ids (`livepeer/storyboard`, `mcp_at_*`, env key names) stay as-is — do not “rebrand” them in code. + +--- + +## 4. Exact contract + +### 4.1 Login bridge (browser) + +**Entry (Storyboard → Console)** + +``` +GET {SSO_MINT_ORIGIN}/login?mcp_oauth=1&state={opaque}&redirect_uri={callback} +``` + +| Query | Required | Rules | +|-------|----------|--------| +| `mcp_oauth` | yes | Must be `1` to engage bridge (ignore for normal Console login). | +| `state` | yes | Opaque ≤512 chars; echo unchanged on callback. | +| `redirect_uri` | yes | Exact match against redirect allowlist (see §4.4). | + +**Behavior** + +1. Persist pending `{ state, redirectUri }` (signed httpOnly cookie, short TTL ~10m) — see NaaP `encodeMcpOauthPendingCookie`. +2. Run Auth0 login **without** forcing `returnTo=/home` for this path. +3. After session exists, complete bridge (NaaP pattern: `GET /api/v1/auth/mcp/complete` or inline equivalent). +4. Redirect to allowlisted `redirect_uri` with: + +| Param | Required | Source | +|-------|----------|--------| +| `state` | yes | Echo pending.state | +| `external_user_id` | yes | Console hashed subject `eu_` (same as dashboard keys). **Not** raw `auth0|…` — `|` is outside the PymtHouse charset. | +| `code` | yes | Signed login grant (`mcp_id_…`). Mint subject comes from this, not a caller-chosen id. | +| `email` | optional | Session email | + +**Example success redirect** + +``` +https://agent.livepeer.org/api/mcp/oauth/callback?state=…&external_user_id=eu_…&code=mcp_id_…&email=user%40example.com +``` + +Agent must mint with `code`. Treat `external_user_id` as the canonical id to display/bind, not as a mint-input Agent can invent. + +Reject disallowed `redirect_uri`. On missing session / bad pending → fail closed (login error or safe Console page — never open redirect). + +### 4.2 Identity exchange (optional, server) + +Agent may redeem the callback `code` without minting yet: + +``` +POST {SSO_MINT_ORIGIN}/api/v1/auth/mcp/identity +Content-Type: application/json + +{ "code": "mcp_id_…" } +``` + +**200:** `{ "externalUserId": "eu_…", "email": "…" }` — hashed Console subject +**400:** invalid JSON / missing code +**401:** invalid or expired code + +Mint still requires the same `code`. Do not use this response as a free-form mint id. + +### 4.3 Internal mint (server-to-server, connect-time only) + +``` +POST /api/internal/mcp/mint +Authorization: Bearer +Content-Type: application/json +X-Mcp-Caller-Origin: https://agent.livepeer.org +# Or Origin: https://agent.livepeer.org +``` + +**Body** + +```json +{ + "code": "mcp_id_…", + "label": "mcp-oauth" +} +``` + +| Field | Required | Notes | +|-------|----------|--------| +| `code` | yes | Login grant from the callback (or identity redeem). Subject is `eu_…` inside the code. | +| `externalUserId` | no | If present must equal the code’s subject; never used as the mint id by itself | +| `email` | no | Overrides email on the grant if set | +| `label` | no | Default `"mcp-oauth"` | + +**Caller origin:** Prefer `Origin`; if absent (S2S), accept `X-Mcp-Caller-Origin`. Value must be in `MCP_INTERNAL_MINT_ALLOWLIST` (exact string match). + +**Success 200** — return **only**: + +```json +{ "apiKey": "app_98575870d7ae33589a3f0660_pmth_" } +``` + +Composite shape: `app__pmth_`. Never include key metadata that leaks the secret elsewhere; never log `apiKey`. + +**Status codes (match NaaP)** + +| Status | When | Body example | +|--------|------|----------------| +| 404 | `MCP_INTERNAL_MINT_SECRET` or `MCP_INTERNAL_MINT_ALLOWLIST` unset/empty | `{ "error": "not_found" }` | +| 401 | Missing/wrong Bearer, or invalid/expired `code` | `{ "error": "unauthorized" }` | +| 403 | Caller origin missing or not allowlisted | `{ "error": "forbidden" }` | +| 503 | `PYMTHOUSE_PUBLIC_CLIENT_ID` ≠ `app_98575870d7ae33589a3f0660` (RS-2) | `{ "error": "billing_app_mismatch", "error_description": "…" }` | +| 400 | Bad/missing JSON, missing `code`, or `externalUserId` mismatch vs code | `{ "error": "invalid_request", … }` | +| 502 | Upstream Builder mint failure | `{ "error": "mint_failed", "error_description": "Unable to mint credential" }` — **no upstream leak** | +| 200 | Mint OK | `{ "apiKey": "…" }` | + +**Implementation:** Reuse/wrap Console’s pymthouse M2M key mint (same idea as NaaP `createPymthouseApiKey`). Do not reimplement Basic auth differently from existing Console BFF if one already exists (console#3/#8 lineage). + +### 4.4 Allowlists & env (Console) + +| Env | Purpose | +|-----|---------| +| `MCP_INTERNAL_MINT_SECRET` | Shared Bearer with Storyboard (`SSO_MINT_SECRET` / `MCP_INTERNAL_MINT_SECRET`) | +| `MCP_INTERNAL_MINT_ALLOWLIST` | Comma-separated exact origins, e.g. `https://agent.livepeer.org` (+ Preview Agent origins) | +| `MCP_OAUTH_REDIRECT_ALLOWLIST` | Optional explicit full callback URLs; else derive `{each mint allowlist origin}/api/mcp/oauth/callback` | +| `MCP_OAUTH_BRIDGE_SECRET` | Optional cookie signing secret; may fall back to Auth0/session secret or mint secret (see NaaP) | +| `PYMTHOUSE_PUBLIC_CLIENT_ID` | Billing app id — **must** be `app_98575870d7ae33589a3f0660` in non-prod | +| `PYMTHOUSE_M2M_CLIENT_ID` | Confidential `m2m_…` | +| `PYMTHOUSE_M2M_CLIENT_SECRET` | M2M secret (server-only) | +| `PYMTHOUSE_ISSUER_URL` | e.g. `https://pymthouse.com/api/v1/oidc` | +| Auth0 vars | From console#14 — unchanged | + +**Storyboard side (document for operators; do not implement in Console)** + +```bash +MCP_OAUTH_ENABLED=1 +MCP_OAUTH_PROVIDER=sso_mint # or alias naap +SSO_MINT_ORIGIN=https:// +# SSO_MINT_AUTHORIZE_URL=https:///login?mcp_oauth=1 +SSO_MINT_URL=https:///api/internal/mcp/mint +SSO_MINT_SECRET= +SSO_MINT_CALLER_ORIGIN=https://agent.livepeer.org +MCP_OAUTH_BILLING_APP_ID=app_98575870d7ae33589a3f0660 +``` + +Until `SSO_MINT_*` lands on Storyboard, equivalent names may be `NAAP_MCP_ORIGIN` / `NAAP_MCP_MINT_URL` / `MCP_INTERNAL_MINT_*`. + +--- + +## 5. Deliverables (ordered PR slices) + +Ship as separate Console PRs. Do not redo Auth0 from #14. + +### PR-A — Login bridge + +1. Read NaaP `mcp-oauth-login-bridge.ts` + `api/v1/auth/mcp/complete/route.ts`. +2. On `/login`, when `mcp_oauth=1`, capture `state` + `redirect_uri`; validate allowlist; set signed pending cookie. +3. After Auth0 session, redirect to Storyboard callback with `state` + hashed `external_user_id` (`eu_…`) + `code` + optional `email` — **not** `/home`. +4. Unit tests: allowlist reject, missing state, happy callback URL shape. + +### PR-B — Internal mint + M2M + +1. Port NaaP `POST /api/internal/mcp/mint` behavior (status matrix §4.3) under Console App Router equivalent path **`/api/internal/mcp/mint`** (keep path identical so Storyboard env is drop-in). +2. Wire `createPymthouseApiKey`-equivalent using Console M2M env. +3. Enforce RS-2 test-app pin → 503 otherwise. +4. Unit tests: 404/401/403/503/400/502/200 + `X-Mcp-Caller-Origin` without `Origin` (copy NaaP `route.test.ts` cases). + +### PR-C — Env docs + smokes + +1. Document Console env + Storyboard `SSO_MINT_*` sketch (no secret values). +2. Add curl smokes (§6) to a Console docs/test note. +3. Confirm Preview deploy: mint 404 until secrets set; then 401/403/200 matrix. + +**PR-D** — Bind mint to login: issue `mcp_id_…` at complete; `POST /api/v1/auth/mcp/identity`; mint requires `code` (no free-form subject). + +--- + +## 6. Acceptance tests / verify commands + +Replace `CONSOLE_ORIGIN`, secrets, and subjects. Never print real `apiKey` in logs/CI artifacts. + +### Mint — fail closed / auth matrix + +```bash +CONSOLE_ORIGIN=https:// +SECRET=dev-shared-secret # must match Console MCP_INTERNAL_MINT_SECRET +CALLER=https://agent.livepeer.org + +# Unconfigured env on a deploy without secret/allowlist → 404 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST "$CONSOLE_ORIGIN/api/internal/mcp/mint" \ + -H 'content-type: application/json' \ + -d '{"code":"mcp_id_smoke"}' +# expect: 404 + +# Wrong Bearer → 401 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST "$CONSOLE_ORIGIN/api/internal/mcp/mint" \ + -H "authorization: Bearer wrong" \ + -H "x-mcp-caller-origin: $CALLER" \ + -H 'content-type: application/json' \ + -d '{"code":"mcp_id_smoke"}' +# expect: 401 + +# Bad origin → 403 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST "$CONSOLE_ORIGIN/api/internal/mcp/mint" \ + -H "authorization: Bearer $SECRET" \ + -H 'x-mcp-caller-origin: https://evil.example' \ + -H 'content-type: application/json' \ + -d '{"code":"mcp_id_smoke"}' +# expect: 403 + +# Free-form id without login code → 400 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST "$CONSOLE_ORIGIN/api/internal/mcp/mint" \ + -H "authorization: Bearer $SECRET" \ + -H "x-mcp-caller-origin: $CALLER" \ + -H 'content-type: application/json' \ + -d '{"externalUserId":"smoke-user"}' +# expect: 400 + +# Happy path: use `code` from the login callback (inspect locally; do not commit apiKey) +curl -sS -X POST "$CONSOLE_ORIGIN/api/internal/mcp/mint" \ + -H "authorization: Bearer $SECRET" \ + -H "x-mcp-caller-origin: $CALLER" \ + -H 'content-type: application/json' \ + -d '{"code":"'"$LOGIN_CODE"'","label":"mcp-oauth"}' +# expect: {"apiKey":"app_98575870d7ae33589a3f0660_pmth_…"} +``` + +### Login bridge — manual / browser + +1. Open: + `https:///login?mcp_oauth=1&state=test-state-1&redirect_uri=https%3A%2F%2Fagent.livepeer.org%2Fapi%2Fmcp%2Foauth%2Fcallback` +2. Complete Auth0. +3. Land on Agent callback URL with same `state=test-state-1`, `external_user_id=eu_…`, and `code=mcp_id_…` (not raw `auth0|…`, not Console `/home`). +4. Disallowed `redirect_uri=https://evil.example/cb` must not redirect there. + +### Unit + +Port NaaP mint `route.test.ts` cases. Add login-bridge allowlist + callback param tests. + +--- + +## 7. Out of scope + +- Storyboard MCP OAuth broker, PRM, PKCE AS, `mcp_at_*` oauth-store, Auth Resolution +- Live Runner / SDK / signer / OpenMeter changes +- Scenario B (pymthouse-direct OIDC `web_` AS + Storyboard-held M2M) +- Storyboard SM-1…SM-4 provider rename / `SSO_MINT_*` wiring (Storyboard owns) +- Replacing or redoing Console Auth0 UI from #14 +- Billing settings UI, device-code flows (adjacent Console PRs — not this contract) + +--- + +## 8. Done criteria + +Mark complete only when all are true: + +- [ ] `GET /login?mcp_oauth=1&state=…&redirect_uri=…` → Auth0 → Storyboard callback with echoed `state`, hashed `external_user_id` (`eu_…`), and `code` +- [ ] MCP path does not force `returnTo=/home` +- [ ] Redirect allowlist includes `https://agent.livepeer.org/api/mcp/oauth/callback` (+ previews if used); open redirects rejected +- [ ] `POST /api/internal/mcp/mint` implements §4.3 status matrix (404/401/403/503/400/502/200); subject from `code` only +- [ ] Caller allowlist includes `https://agent.livepeer.org`; accepts `X-Mcp-Caller-Origin` when `Origin` absent +- [ ] Non-prod `PYMTHOUSE_PUBLIC_CLIENT_ID` pinned to `app_98575870d7ae33589a3f0660`; mismatch → 503 +- [ ] M2M env present on the deploy that hosts mint; composite never logged +- [ ] Curl smokes §6 green on Preview +- [ ] Operator note lists Console URLs + Storyboard `SSO_MINT_*` / `NAAP_MCP_*` mapping (no secrets committed) +- [ ] Ready for Storyboard to point `SSO_MINT_*` at Console for Scenario A parity (same bar as NaaP GREEN: SSO → mint → `create_media` → signer) diff --git a/CONSOLE-SSO-MINT-PARTNER-FOR-JOHN.md b/CONSOLE-SSO-MINT-PARTNER-FOR-JOHN.md new file mode 100644 index 0000000..80d767e --- /dev/null +++ b/CONSOLE-SSO-MINT-PARTNER-FOR-JOHN.md @@ -0,0 +1,80 @@ +# Livepeer Console as SSO + mint partner (for John) + +**Scenario A in one line:** Livepeer Console becomes the login + credential-mint partner for Livepeer Agent MCP (same role NaaP plays today). Users sign in on Console; Agent then mints a pymthouse composite server-to-server and can run MCP **keyless**. Console only owns the login bridge and mint API — not the MCP broker itself. + +--- + +## Links + +| What | Link | +|------|------| +| Agent / Storyboard contract + gap matrix | [storyboard#1192](https://github.com/livepeer/storyboard/pull/1192) (§4 + §12) | +| Console Auth0 baseline (already shipped) | [console#14](https://github.com/livepeer/console/pull/14) | +| NaaP mint reference (merged) | [naap#458](https://github.com/livepeer/naap/pull/458) — `POST /api/internal/mcp/mint` | +| Hosts | MCP: `https://agent.livepeer.org` · Callback: `https://agent.livepeer.org/api/mcp/oauth/callback` | + +--- + +## What’s already on Console + +- **Auth0 UI login** from [console#14](https://github.com/livepeer/console/pull/14) — session + Auth0 `sub` as stable subject. **Don’t redo this**; extend it for MCP query params and post-login redirect. + +--- + +## What Console still needs + +Think of two small surfaces plus env/wiring: + +### 1. Login bridge (browser) + +Agent sends the user to Console: + +`/login?mcp_oauth=1&state=…&redirect_uri=https://agent.livepeer.org/api/mcp/oauth/callback` + +Console should: + +- Capture `state` + `redirect_uri` (short-lived signed cookie) +- Run Auth0 **without** forcing `returnTo=/home` on this path +- After login, redirect to the Agent callback with the same `state`, `external_user_id` (Console `eu_`, not raw Auth0 `sub`), one-time `code`, optional `email` +- Allowlist redirects only (Agent callback + previews); reject open redirects + +### 2. Internal mint API (server-to-server) + +`POST /api/internal/mcp/mint` — Bearer shared secret, caller-origin allowlist (e.g. `https://agent.livepeer.org`), body `{ code, label? }` (`code` from the login callback). Subject is taken from the code, not a free-form `externalUserId`. + +On success return **only** `{ "apiKey": "app_…_pmth_…" }`. Fail closed: missing secret/allowlist → **404**, bad Bearer → **401**, bad origin → **403**, missing/invalid code → **400**/**401**, wrong billing app in non-prod → **503**. Match NaaP fail-closed mint auth; do not invent a JWT-only mint path. + +### 3. Allowlists, pymthouse M2M, env + +- Mint secret + caller-origin allowlist (shared with Agent / Storyboard) +- Redirect allowlist for the login bridge +- pymthouse M2M client id/secret + issuer; non-prod billing app pinned to the RS-2 test app (`app_98575870d7ae33589a3f0660`) +- Short operator note: Console URLs ↔ Agent `SSO_MINT_*` (or today’s `NAAP_MCP_*` fallbacks) — no secrets in git + +**Suggested PR slices (optional):** (A) login bridge · (B) mint + M2M · (C) env docs + smokes · (D) bind mint to login `code` (no free-form subject). + +--- + +## Explicitly out of scope for Console + +- Agent MCP OAuth broker, PRM, `mcp_at_*` store, Auth Resolution +- Live Runner / SDK / signer / billing dispatch +- Scenario B (pymthouse-direct OIDC) +- Storyboard-side `SSO_MINT_*` wiring (Agent repo owns that) +- Redoing Auth0 UI from #14 +- Billing settings UI / device-code flows + +--- + +## Done means + +A user can hit Console login with `mcp_oauth=1`, finish Auth0, and land on `https://agent.livepeer.org/api/mcp/oauth/callback` with echoed `state`, hashed `external_user_id`, and `code`. Agent mints with that `code` (shared secret) and gets a composite `apiKey` (never logged). Preview smokes show the fail-closed matrix (404/401/403/200). At that point Storyboard can point `SSO_MINT_*` at Console for Scenario A parity with NaaP — SSO → mint → `create_media` on the existing billed path. + +### How we’ll verify (short) + +1. Browser: MCP login URL → Auth0 → Agent callback with `state` + `eu_…` `external_user_id` + `code` (not `/home`; evil `redirect_uri` rejected). +2. Mint: unconfigured → 404; wrong Bearer → 401; bad origin → 403; body without `code` → 400; happy path `{ code }` → 200 `{ apiKey }` only (inspect locally, don’t paste secrets into Slack/CI). + +--- + +*Detailed agent implementation brief (paste into Claude/Codex in the Console repo): [`docs/CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md`](./CONSOLE-SSO-MINT-PARTNER-BUILD-LIST.md)* diff --git a/app/(app)/device/DeviceApproveForm.tsx b/app/(app)/device/DeviceApproveForm.tsx new file mode 100644 index 0000000..a4fc585 --- /dev/null +++ b/app/(app)/device/DeviceApproveForm.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import Button from "@/components/design-system/Button"; + +export default function DeviceApproveForm({ + iss, + targetLinkUri, + userCode, + clientId, +}: { + iss: string; + targetLinkUri: string; + userCode: string; + clientId: string; +}) { + const [phase, setPhase] = useState<"idle" | "submitting" | "ok" | "error">( + "idle" + ); + const [error, setError] = useState(""); + + async function approve() { + setError(""); + setPhase("submitting"); + const response = await fetch("/api/v1/auth/device/approve", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + iss, + target_link_uri: targetLinkUri, + }), + }); + const json = (await response.json()) as { + ok?: boolean; + error?: string; + }; + if (!response.ok || !json.ok) { + setPhase("error"); + setError(json.error ?? "Approval failed"); + return; + } + setPhase("ok"); + } + + if (phase === "ok") { + return ( +

+ Device approved. Return to Storyboard — polling will finish on its own. +

+ ); + } + + return ( +
+

+ Approve this device code for app{" "} + {clientId}. +

+

+ {userCode} +

+ + {error ?

{error}

: null} +
+ ); +} diff --git a/app/(app)/device/page.tsx b/app/(app)/device/page.tsx new file mode 100644 index 0000000..58b9233 --- /dev/null +++ b/app/(app)/device/page.tsx @@ -0,0 +1,62 @@ +import { redirect } from "next/navigation"; +import { Smartphone } from "lucide-react"; + +import { auth0 } from "@/lib/auth0"; +import ConsolePageHeader from "@/components/console/ConsolePageHeader"; +import { + parseDeviceInitiateParams, +} from "@/lib/console/device-approval"; +import DeviceApproveForm from "./DeviceApproveForm"; + +export const dynamic = "force-dynamic"; + +export default async function DevicePage({ + searchParams, +}: { + searchParams: Promise<{ + iss?: string; + target_link_uri?: string; + login_hint?: string; + }>; +}) { + const params = await searchParams; + const query = new URLSearchParams(); + if (params.iss) query.set("iss", params.iss); + if (params.target_link_uri) query.set("target_link_uri", params.target_link_uri); + if (params.login_hint) query.set("login_hint", params.login_hint); + const returnTo = `/device${query.size ? `?${query.toString()}` : ""}`; + + const session = await auth0.getSession(); + if (!session?.user?.sub) { + redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`); + } + + let parsed; + try { + parsed = parseDeviceInitiateParams(query); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid device request"; + return ( + <> + +
+

{message}

+
+ + ); + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 0256ea6..56fe1fc 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { auth0 } from "@/lib/auth0"; import LoginPage from "@/components/console/LoginPage"; +import { isDeviceReturnTo } from "@/lib/console/device-initiate"; import { decodeMcpOauthPendingCookie, MCP_OAUTH_COMPLETE_PATH, @@ -18,6 +19,7 @@ export default async function LoginRoute({ mcp_bridge?: string; state?: string; redirect_uri?: string; + returnTo?: string; }>; }) { const params = await searchParams; @@ -43,15 +45,29 @@ export default async function LoginRoute({ ); const mcpBridge = params.mcp_bridge === "1" && pending !== null; + const deviceReturnTo = + typeof params.returnTo === "string" && isDeviceReturnTo(params.returnTo) + ? params.returnTo + : null; + const session = await auth0.getSession(); if (session) { if (mcpBridge) { redirect(MCP_OAUTH_COMPLETE_PATH); } + if (deviceReturnTo) { + redirect(deviceReturnTo); + } redirect("/home"); } return ( - + ); } diff --git a/app/api/v1/auth/device/approve/route.ts b/app/api/v1/auth/device/approve/route.ts new file mode 100644 index 0000000..95c763d --- /dev/null +++ b/app/api/v1/auth/device/approve/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { pymthouseErrorResponse } from "@/app/api/pymthouse/route-helpers"; +import { + approveDevice, + parseDeviceInitiateParams, +} from "@/lib/console/device-approval"; +import { requireConsoleSession } from "@/lib/console/session-user"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest): Promise { + try { + const session = await requireConsoleSession(); + const body = (await request.json()) as { + iss?: unknown; + target_link_uri?: unknown; + }; + const params = new URLSearchParams(); + if (typeof body.iss === "string") { + params.set("iss", body.iss); + } + if (typeof body.target_link_uri === "string") { + params.set("target_link_uri", body.target_link_uri); + } + const parsed = parseDeviceInitiateParams(params); + await approveDevice({ + userCode: parsed.userCode, + clientId: parsed.clientId, + externalUserId: session.externalUserId, + email: session.email, + }); + return NextResponse.json({ ok: true }); + } catch (error) { + return pymthouseErrorResponse(error, "Device approval failed"); + } +} diff --git a/docs/SSO-MINT-OPERATOR.md b/docs/SSO-MINT-OPERATOR.md index 43250d6..99d82b8 100644 --- a/docs/SSO-MINT-OPERATOR.md +++ b/docs/SSO-MINT-OPERATOR.md @@ -10,14 +10,15 @@ MCP_INTERNAL_MINT_ALLOWLIST=https://agent.livepeer.org # MCP_OAUTH_REDIRECT_ALLOWLIST=https://agent.livepeer.org/api/mcp/oauth/callback # MCP_OAUTH_BRIDGE_SECRET= # falls back to mint secret or AUTH0_SECRET PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc -PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660 # required in non-prod +# Public sibling of the M2M (Builder path id until Console infers it from M2M) +PYMTHOUSE_PUBLIC_CLIENT_ID=app_… PYMTHOUSE_M2M_CLIENT_ID=m2m_… PYMTHOUSE_M2M_CLIENT_SECRET=pmth_cs_… ``` -Mint is `POST /api/internal/mcp/mint`. Missing secret or empty allowlist → **404**. Wrong Bearer → **401**. Bad `Origin` / `X-Mcp-Caller-Origin` → **403**. Wrong billing app in non-prod → **503** `billing_app_mismatch`. +Mint is `POST /api/internal/mcp/mint` with `{ "code": "mcp_id_…" }` from the login callback. Missing secret or empty allowlist → **404**. Wrong Bearer → **401**. Bad `Origin` / `X-Mcp-Caller-Origin` → **403**. Missing/invalid code → **400** / **401**. The billed app is the public sibling of the configured M2M — mint does not pin a hard-coded test app id. Free-form `externalUserId` is not a mint subject. -Login: `GET /login?mcp_oauth=1&state=…&redirect_uri=https://agent.livepeer.org/api/mcp/oauth/callback` → Auth0 → callback with `state` + `external_user_id` (`eu_` of Auth0 `sub`, same as Console keys). +Login: `GET /login?mcp_oauth=1&state=…&redirect_uri=https://agent.livepeer.org/api/mcp/oauth/callback` → Auth0 → callback with `state` + `external_user_id` (`eu_` of Auth0 `sub`, same as Console keys) + `code`. Optional redeem: `POST /api/v1/auth/mcp/identity` `{ "code" }`. ## Storyboard / Agent @@ -28,7 +29,6 @@ SSO_MINT_ORIGIN=https:// SSO_MINT_URL=https:///api/internal/mcp/mint SSO_MINT_SECRET= SSO_MINT_CALLER_ORIGIN=https://agent.livepeer.org -MCP_OAUTH_BILLING_APP_ID=app_98575870d7ae33589a3f0660 ``` Until `SSO_MINT_*` lands, equivalent names may be `NAAP_MCP_ORIGIN` / `NAAP_MCP_MINT_URL` / `MCP_INTERNAL_MINT_*`. diff --git a/lib/console/device-approval.test.ts b/lib/console/device-approval.test.ts new file mode 100644 index 0000000..af5924c --- /dev/null +++ b/lib/console/device-approval.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { PmtHouseError } from "@pymthouse/builder-sdk"; + +import { + isDeviceReturnTo, + parseDeviceInitiateParams, +} from "./device-initiate.ts"; + +const ISSUER = "https://pymthouse.com/api/v1/oidc"; +const CLIENT = "app_98575870d7ae33589a3f0660"; +const TARGET = `${new URL(ISSUER).origin}/oidc/device?user_code=ABCD-EFGH&client_id=${CLIENT}`; + +function params(overrides?: Record): URLSearchParams { + return new URLSearchParams({ + iss: ISSUER, + target_link_uri: TARGET, + ...overrides, + }); +} + +test("isDeviceReturnTo allows only /device paths", () => { + assert.equal(isDeviceReturnTo("/device"), true); + assert.equal(isDeviceReturnTo("/device?iss=x"), true); + assert.equal(isDeviceReturnTo("/home"), false); + assert.equal(isDeviceReturnTo("/device/evil"), false); + assert.equal(isDeviceReturnTo("https://evil.example/device"), false); +}); + +test("parseDeviceInitiateParams rejects a client_id that is not the configured app", () => { + assert.throws( + () => + parseDeviceInitiateParams(params(), { + parseDeviceApprovalRedirect: () => ({ + issuer: ISSUER, + targetLinkUri: TARGET, + userCode: "ABCDEFGH", + clientId: "app_other", + }), + }, CLIENT), + (err: unknown) => + err instanceof PmtHouseError && err.code === "invalid_client" + ); +}); + +test("parseDeviceInitiateParams returns the SDK parse when client_id matches", () => { + const parsed = parseDeviceInitiateParams( + params(), + { + parseDeviceApprovalRedirect: () => ({ + issuer: ISSUER, + targetLinkUri: TARGET, + userCode: "ABCDEFGH", + clientId: CLIENT, + }), + }, + CLIENT + ); + assert.equal(parsed.userCode, "ABCDEFGH"); + assert.equal(parsed.clientId, CLIENT); +}); diff --git a/lib/console/device-approval.ts b/lib/console/device-approval.ts new file mode 100644 index 0000000..b5ae033 --- /dev/null +++ b/lib/console/device-approval.ts @@ -0,0 +1,42 @@ +import "server-only"; + +import { PmtHouseError } from "@pymthouse/builder-sdk"; + +import { + parseDeviceInitiateParams as parseDeviceInitiateParamsWithClient, +} from "@/lib/console/device-initiate"; +import { createPmtHouseClientForPublicApp } from "@/lib/console/pymthouse-bff"; +import { readPublicClientId } from "@/lib/console/pymthouse-http"; + +export { isDeviceReturnTo } from "@/lib/console/device-initiate"; + +export function parseDeviceInitiateParams(searchParams: URLSearchParams) { + const publicClientId = readPublicClientId(); + return parseDeviceInitiateParamsWithClient( + searchParams, + createPmtHouseClientForPublicApp(publicClientId), + publicClientId + ); +} + +export async function approveDevice(input: { + userCode: string; + clientId: string; + externalUserId: string; + email?: string; +}): Promise { + const publicClientId = readPublicClientId(); + if (input.clientId !== publicClientId) { + throw new PmtHouseError("clientId does not match configured public client", { + status: 400, + code: "invalid_client", + }); + } + const client = createPmtHouseClientForPublicApp(publicClientId); + await client.approveDeviceLogin({ + externalUserId: input.externalUserId, + userCode: input.userCode, + email: input.email, + publicClientId, + }); +} diff --git a/lib/console/device-initiate.ts b/lib/console/device-initiate.ts new file mode 100644 index 0000000..167f1a4 --- /dev/null +++ b/lib/console/device-initiate.ts @@ -0,0 +1,33 @@ +import { PmtHouseError, type ParsedDeviceApprovalRedirect } from "@pymthouse/builder-sdk"; + +export type DeviceInitiateParseClient = { + parseDeviceApprovalRedirect: ( + searchParams: URLSearchParams + ) => ParsedDeviceApprovalRedirect; +}; + +export function parseDeviceInitiateParams( + searchParams: URLSearchParams, + client: DeviceInitiateParseClient, + expectedClientId: string +): ParsedDeviceApprovalRedirect { + const parsed = client.parseDeviceApprovalRedirect(searchParams); + if (parsed.clientId !== expectedClientId) { + throw new PmtHouseError("clientId does not match configured public client", { + status: 400, + code: "invalid_client", + }); + } + return parsed; +} + +/** Auth0 returnTo must stay on /device (query allowed). */ +export function isDeviceReturnTo(returnTo: string): boolean { + if (!returnTo.startsWith("/device")) { + return false; + } + if (returnTo === "/device") { + return true; + } + return returnTo.startsWith("/device?"); +} From c6acf9075c4eb6792100aa530cd2023a096cc5e3 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 26 Aug 2026 19:38:31 -0400 Subject: [PATCH 5/6] feat(device): refactor DeviceApproveForm and introduce DevicePageChrome component - Added DevicePageChrome component to encapsulate the header and layout for device approval pages. - Updated DeviceApproveForm usage in the device page to utilize the new DevicePageChrome for consistent UI structure. - Removed redundant imports and streamlined error handling presentation within the new layout. --- app/(app)/device/DeviceApproveForm.tsx | 13 +++++++++- app/(app)/device/page.tsx | 36 ++++++++++---------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/(app)/device/DeviceApproveForm.tsx b/app/(app)/device/DeviceApproveForm.tsx index a4fc585..2da07d9 100644 --- a/app/(app)/device/DeviceApproveForm.tsx +++ b/app/(app)/device/DeviceApproveForm.tsx @@ -1,7 +1,18 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; +import { Smartphone } from "lucide-react"; import Button from "@/components/design-system/Button"; +import ConsolePageHeader from "@/components/console/ConsolePageHeader"; + +export function DevicePageChrome({ children }: { children: ReactNode }) { + return ( + <> + +
{children}
+ + ); +} export default function DeviceApproveForm({ iss, diff --git a/app/(app)/device/page.tsx b/app/(app)/device/page.tsx index 58b9233..68b8817 100644 --- a/app/(app)/device/page.tsx +++ b/app/(app)/device/page.tsx @@ -1,12 +1,8 @@ import { redirect } from "next/navigation"; -import { Smartphone } from "lucide-react"; import { auth0 } from "@/lib/auth0"; -import ConsolePageHeader from "@/components/console/ConsolePageHeader"; -import { - parseDeviceInitiateParams, -} from "@/lib/console/device-approval"; -import DeviceApproveForm from "./DeviceApproveForm"; +import { parseDeviceInitiateParams } from "@/lib/console/device-approval"; +import DeviceApproveForm, { DevicePageChrome } from "./DeviceApproveForm"; export const dynamic = "force-dynamic"; @@ -37,26 +33,20 @@ export default async function DevicePage({ } catch (error) { const message = error instanceof Error ? error.message : "Invalid device request"; return ( - <> - -
-

{message}

-
- + +

{message}

+
); } return ( - <> - -
- -
- + + + ); } From 25866093583d8f71393f10f14c2ec203e63d101d Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 28 Aug 2026 02:36:08 -0400 Subject: [PATCH 6/6] fix(billing): route owner-rollup sessions through Owner Paid APIs (#24) Console end-users on the shared owner wallet were hitting app-retail subscription and session-wallet endpoints, which hid the wallet and rejected plan changes with owner_wallet_not_app_user. --- app/api/pymthouse/plans/route.ts | 4 +- components/console/PlansPanel.tsx | 12 +- components/console/WalletPanel.tsx | 29 +-- lib/console/owner-billing-rail.test.ts | 77 +++++++ lib/console/owner-billing-rail.ts | 161 +++++++++++++++ lib/console/pymthouse-billing-bff.ts | 165 ++++++++++----- lib/console/pymthouse-me-billing-bff.ts | 34 ++- lib/console/pymthouse-owner-billing-bff.ts | 228 +++++++++++++++++++++ lib/console/useBillingPlans.ts | 5 +- 9 files changed, 629 insertions(+), 86 deletions(-) create mode 100644 lib/console/owner-billing-rail.test.ts create mode 100644 lib/console/owner-billing-rail.ts create mode 100644 lib/console/pymthouse-owner-billing-bff.ts diff --git a/app/api/pymthouse/plans/route.ts b/app/api/pymthouse/plans/route.ts index cf483ba..8d638c2 100644 --- a/app/api/pymthouse/plans/route.ts +++ b/app/api/pymthouse/plans/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { listDashboardBillingPlans } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; import { PYMTHOUSE_NO_STORE_HEADERS, pymthouseErrorResponse, @@ -9,7 +10,8 @@ export const runtime = "nodejs"; export async function GET() { try { - const plans = await listDashboardBillingPlans(); + const session = await requireConsoleSession(); + const plans = await listDashboardBillingPlans(session.externalUserId); return NextResponse.json( { plans }, { headers: PYMTHOUSE_NO_STORE_HEADERS } diff --git a/components/console/PlansPanel.tsx b/components/console/PlansPanel.tsx index f912b1f..d8c0a90 100644 --- a/components/console/PlansPanel.tsx +++ b/components/console/PlansPanel.tsx @@ -94,7 +94,8 @@ export default function PlansPanel({ const { isConnected } = useAuth(); const { state, reload, subscribe, changePlan } = useBillingPlans(isConnected); const merchant = - sessionBilling?.surface?.mode === "merchant" + sessionBilling?.surface?.mode === "merchant" && + sessionBilling.surface.wallet ? sessionBilling.surface : null; const wallet = useWalletBillingState( @@ -342,9 +343,7 @@ export default function PlansPanel({

Plans

-

- {planSub} -

+

{planSub}

{flash === "success" ? (

Payment method saved @@ -377,7 +376,10 @@ export default function PlansPanel({ ? ` · ${plan.capabilityCount} capabilities` : ""}

- {isCurrent && included && !included.sharedWithApp && included.planId === plan.id ? ( + {isCurrent && + included && + !included.sharedWithApp && + included.planId === plan.id ? (

${included.remainingUsd} of ${included.totalUsd} included left diff --git a/components/console/WalletPanel.tsx b/components/console/WalletPanel.tsx index 4c90f5d..7da2951 100644 --- a/components/console/WalletPanel.tsx +++ b/components/console/WalletPanel.tsx @@ -37,7 +37,11 @@ function clearTopUpQueryParam(): void { const url = new URL(window.location.href); if (!url.searchParams.has("topup")) return; url.searchParams.delete("topup"); - window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + window.history.replaceState( + {}, + "", + `${url.pathname}${url.search}${url.hash}` + ); } function formatInvoiceDate(iso: string | undefined): string { @@ -81,7 +85,8 @@ export default function WalletPanel({ }) { const { isConnected } = useAuth(); const merchant = - sessionBilling?.surface?.mode === "merchant" + sessionBilling?.surface?.mode === "merchant" && + sessionBilling.surface.wallet ? sessionBilling.surface : null; const waitingSession = @@ -183,22 +188,6 @@ export default function WalletPanel({ ); } - if (merchant && !merchant.wallet) { - return ( -

-

Could not load session wallet.

- -
- ); - } - const { wallet, paymentMethods, invoices } = merchant ? { wallet: merchant.wallet, @@ -259,7 +248,9 @@ export default function WalletPanel({ {runway.usd}

{runway.detail ? ( -

{runway.detail}

+

+ {runway.detail} +

) : null} {limitNote ? (

{limitNote}

diff --git a/lib/console/owner-billing-rail.test.ts b/lib/console/owner-billing-rail.test.ts new file mode 100644 index 0000000..9beac38 --- /dev/null +++ b/lib/console/owner-billing-rail.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + catalogPlanIdForOwnerKey, + isOwnerPaidPlanKey, + isOwnerStarterPlanKey, + isOwnerWalletPlanKey, + mapOwnerCatalogPlans, + mapOwnerUserSubscription, + OWNER_STARTER_PLAN_KEY, +} from "./owner-billing-rail"; + +test("classifies Owner Sandbox Starter keys including amount variants", () => { + assert.equal(isOwnerStarterPlanKey("pymthouse_owner_starter"), true); + assert.equal(isOwnerStarterPlanKey("pymthouse_owner_starter_50000000"), true); + assert.equal(isOwnerStarterPlanKey("app_starter"), false); + assert.equal(isOwnerStarterPlanKey(""), false); +}); + +test("classifies Owner Paid tier keys", () => { + assert.equal(isOwnerPaidPlanKey("pymthouse_owner_paid"), true); + assert.equal(isOwnerPaidPlanKey("pymthouse_owner_paid_producer"), true); + assert.equal(isOwnerPaidPlanKey("pymthouse_owner_starter"), false); +}); + +test("owner-wallet plan keys cover starter and paid", () => { + assert.equal(isOwnerWalletPlanKey("pymthouse_owner_starter"), true); + assert.equal(isOwnerWalletPlanKey("pymthouse_owner_paid_scale"), true); + assert.equal(isOwnerWalletPlanKey("pln_retail"), false); +}); + +test("catalog ids collapse starter variants onto the shared Starter row", () => { + assert.equal( + catalogPlanIdForOwnerKey("pymthouse_owner_starter_12000000"), + OWNER_STARTER_PLAN_KEY + ); + assert.equal( + catalogPlanIdForOwnerKey("pymthouse_owner_paid_producer"), + "pymthouse_owner_paid_producer" + ); + assert.equal(catalogPlanIdForOwnerKey("retail"), null); +}); + +test("owner catalog lists Sandbox Starter then paid tiers", () => { + const plans = mapOwnerCatalogPlans([ + { + key: "pymthouse_owner_paid_producer", + name: "Producer", + monthlyFeeUsd: "20.00", + includedUsdMicros: "5000000", + sortOrder: 1, + }, + ]); + assert.equal(plans[0]?.id, OWNER_STARTER_PLAN_KEY); + assert.equal(plans[0]?.isStarterDefault, true); + assert.equal(plans[1]?.id, "pymthouse_owner_paid_producer"); + assert.equal(plans[1]?.type, "subscription"); +}); + +test("owner subscription maps live Starter onto the catalog Starter id", () => { + const sub = mapOwnerUserSubscription({ + livePaidPlanKey: null, + pendingDowngrade: null, + subscriptions: [ + { + subscriptionId: "sub_1", + status: "active", + planName: "Owner Sandbox Starter", + openMeterPlanKey: "pymthouse_owner_starter", + activeTo: null, + }, + ], + }); + assert.equal(sub.planId, OWNER_STARTER_PLAN_KEY); + assert.equal(sub.planName, "Owner Sandbox Starter"); + assert.equal(sub.status, "active"); +}); diff --git a/lib/console/owner-billing-rail.ts b/lib/console/owner-billing-rail.ts new file mode 100644 index 0000000..6209cfb --- /dev/null +++ b/lib/console/owner-billing-rail.ts @@ -0,0 +1,161 @@ +import type { + DashboardBillingPlan, + DashboardUserSubscription, +} from "@/lib/console/pymthouse-billing"; + +/** Platform-wide Owner Starter plan key (shared across all owner wallets). */ +export const OWNER_STARTER_PLAN_KEY = "pymthouse_owner_starter"; + +export const OWNER_STARTER_PLAN_NAME = "Owner Sandbox Starter"; + +/** Prefix for Owner Paid tiers (`pymthouse_owner_paid` or `pymthouse_owner_paid_*`). */ +export const OWNER_PAID_PLAN_KEY_PREFIX = "pymthouse_owner_paid"; + +/** PymtHouse rejects app-retail mutations that would hit the owner wallet. */ +export const OWNER_WALLET_NOT_APP_USER = "owner_wallet_not_app_user"; + +export function isOwnerStarterPlanKey( + planKey: string | null | undefined +): boolean { + const key = planKey?.trim(); + if (!key) return false; + const base = OWNER_STARTER_PLAN_KEY.toLowerCase(); + const lower = key.toLowerCase(); + if (lower === base) return true; + const prefix = `${base}_`; + if (!lower.startsWith(prefix)) return false; + return /^\d+$/.test(lower.slice(prefix.length)); +} + +export function isOwnerPaidPlanKey( + planKey: string | null | undefined +): boolean { + const key = planKey?.trim().toLowerCase(); + if (!key) return false; + if (key === OWNER_PAID_PLAN_KEY_PREFIX) return true; + return key.startsWith(`${OWNER_PAID_PLAN_KEY_PREFIX}_`); +} + +/** True when the live OpenMeter plan is the shared owner wallet, not app retail. */ +export function isOwnerWalletPlanKey( + planKey: string | null | undefined +): boolean { + return isOwnerStarterPlanKey(planKey) || isOwnerPaidPlanKey(planKey); +} + +/** + * Catalog id for an owner-wallet OpenMeter key. Amount-suffixed Starter + * variants collapse onto the shared Starter row. + */ +export function catalogPlanIdForOwnerKey( + planKey: string | null | undefined +): string | null { + const key = planKey?.trim(); + if (!key) return null; + if (isOwnerStarterPlanKey(key)) return OWNER_STARTER_PLAN_KEY; + if (isOwnerPaidPlanKey(key)) return key; + return null; +} + +export type OwnerPaidTierSeed = { + key: string; + name: string; + monthlyFeeUsd: string; + includedUsdMicros: string; + sortOrder: number; +}; + +export function mapOwnerCatalogPlans( + tiers: ReadonlyArray +): DashboardBillingPlan[] { + const starter: DashboardBillingPlan = { + id: OWNER_STARTER_PLAN_KEY, + name: OWNER_STARTER_PLAN_NAME, + type: "free", + status: "active", + priceAmount: "0", + priceCurrency: "USD", + billingCycle: "monthly", + includedUsdMicros: null, + chargeThresholdUsdMicros: null, + resolvedBehavior: null, + capabilityCount: 0, + isStarterDefault: true, + }; + const paid = [...tiers] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map( + (tier): DashboardBillingPlan => ({ + id: tier.key, + name: tier.name.trim() || tier.key, + type: "subscription", + status: "active", + priceAmount: tier.monthlyFeeUsd, + priceCurrency: "USD", + billingCycle: "monthly", + includedUsdMicros: tier.includedUsdMicros, + chargeThresholdUsdMicros: null, + resolvedBehavior: null, + capabilityCount: 0, + isStarterDefault: false, + }) + ); + return [starter, ...paid]; +} + +export type OwnerSubscriptionStatusSeed = { + livePaidPlanKey: string | null; + pendingDowngrade: { + subscriptionId?: string; + planId?: string | null; + planKey?: string | null; + planName?: string | null; + effectiveAt?: string | null; + } | null; + subscriptions: Array<{ + subscriptionId: string; + status: string; + planName: string; + openMeterPlanKey: string | null; + activeTo: string | null; + }>; +}; + +export function mapOwnerUserSubscription( + status: OwnerSubscriptionStatusSeed +): DashboardUserSubscription { + const livePaidKey = status.livePaidPlanKey?.trim() || null; + const liveRow = + status.subscriptions.find((row) => { + const key = row.openMeterPlanKey?.trim() || ""; + if (livePaidKey) return key === livePaidKey; + return isOwnerStarterPlanKey(key); + }) ?? + status.subscriptions[0] ?? + null; + const liveKey = liveRow?.openMeterPlanKey?.trim() || livePaidKey; + const planId = catalogPlanIdForOwnerKey(liveKey); + const pending = status.pendingDowngrade; + return { + planId, + planName: + liveRow?.planName?.trim() || + (planId === OWNER_STARTER_PLAN_KEY + ? OWNER_STARTER_PLAN_NAME + : livePaidKey), + status: liveRow?.status?.trim() || (planId ? "active" : null), + subscriptionId: liveRow?.subscriptionId?.trim() || null, + currentPeriodEnd: liveRow?.activeTo?.trim() || null, + timingOptions: null, + pendingCancel: pending + ? { + subscriptionId: + pending.subscriptionId?.trim() || liveRow?.subscriptionId || "", + planId: pending.planId?.trim() || planId, + planKey: pending.planKey?.trim() || null, + planName: pending.planName?.trim() || OWNER_STARTER_PLAN_NAME, + effectiveAt: pending.effectiveAt?.trim() || null, + } + : null, + }; +} diff --git a/lib/console/pymthouse-billing-bff.ts b/lib/console/pymthouse-billing-bff.ts index 6c34eee..3ce5d8f 100644 --- a/lib/console/pymthouse-billing-bff.ts +++ b/lib/console/pymthouse-billing-bff.ts @@ -30,6 +30,18 @@ import { readPublicClientId, readPymthouseResponse, } from "@/lib/console/pymthouse-http"; +import { + changeOwnerWalletPlan, + cancelOwnerWalletPlan, + getOwnerSubscriptionStatus, + isOwnerWalletMutationError, + listOwnerPaidTiers, + mapOwnerCatalogPlans, + mapOwnerUserSubscription, + resolveSessionBillingRail, + resumeOwnerWalletPlan, +} from "@/lib/console/pymthouse-owner-billing-bff"; +import { isOwnerWalletPlanKey } from "@/lib/console/owner-billing-rail"; export type { DashboardBillingPlan, @@ -86,7 +98,7 @@ function mapProduct(product: BillingProduct): DashboardBillingPlan { }; } -export async function listDashboardBillingPlans(): Promise< +async function listRetailDashboardBillingPlans(): Promise< DashboardBillingPlan[] > { const client = createPmtHouseClientForPublicApp(readPublicClientId()); @@ -97,19 +109,44 @@ export async function listDashboardBillingPlans(): Promise< .sort((a, b) => Number(b.isStarterDefault) - Number(a.isStarterDefault)); } +export async function listDashboardBillingPlans( + externalUserId?: string +): Promise { + if (externalUserId) { + const rail = await resolveSessionBillingRail(externalUserId); + if (rail === "owner") { + return mapOwnerCatalogPlans(await listOwnerPaidTiers()); + } + } + return listRetailDashboardBillingPlans(); +} + export async function startDashboardBillingCheckout(input: { planId: string; externalUserId: string; successUrl?: string; cancelUrl?: string; -}): Promise { +}): Promise { + const useOwner = + isOwnerWalletPlanKey(input.planId) || + (await resolveSessionBillingRail(input.externalUserId)) === "owner"; + if (useOwner) { + return changeOwnerWalletPlan({ planId: input.planId }); + } const client = createPmtHouseClientForPublicApp(readPublicClientId()); - return client.createBillingCheckout({ - planId: input.planId, - externalUserId: input.externalUserId, - ...(input.successUrl ? { successUrl: input.successUrl } : {}), - ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), - }); + try { + return await client.createBillingCheckout({ + planId: input.planId, + externalUserId: input.externalUserId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); + } catch (error) { + if (isOwnerWalletMutationError(error)) { + return changeOwnerWalletPlan({ planId: input.planId }); + } + throw error; + } } export async function changeDashboardBillingSubscription(input: { @@ -121,48 +158,62 @@ export async function changeDashboardBillingSubscription(input: { effectiveAt?: string; confirmReplaceScheduled?: boolean; }): Promise { + const useOwner = + isOwnerWalletPlanKey(input.planId) || + (await resolveSessionBillingRail(input.externalUserId)) === "owner"; + if (useOwner) { + return changeOwnerWalletPlan({ planId: input.planId }); + } + const publicClientId = readPublicClientId(); - const response = await fetch( - `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(input.externalUserId)}/subscription/change`, - { - method: "POST", - headers: { - Authorization: readM2mAuthHeader(), - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - planId: input.planId, - ...(input.successUrl ? { successUrl: input.successUrl } : {}), - ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), - ...(input.timing ? { timing: input.timing } : {}), - ...(input.effectiveAt ? { effectiveAt: input.effectiveAt } : {}), - ...(input.confirmReplaceScheduled - ? { confirmReplaceScheduled: true } - : {}), - }), - cache: "no-store", - } - ); - if (response.status === 409) { - const text = await response.text(); - let body: DashboardScheduledChangeConflict | null = null; - try { - body = text - ? (JSON.parse(text) as DashboardScheduledChangeConflict) - : null; - } catch { - body = null; + try { + const response = await fetch( + `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(input.externalUserId)}/subscription/change`, + { + method: "POST", + headers: { + Authorization: readM2mAuthHeader(), + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + planId: input.planId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + ...(input.timing ? { timing: input.timing } : {}), + ...(input.effectiveAt ? { effectiveAt: input.effectiveAt } : {}), + ...(input.confirmReplaceScheduled + ? { confirmReplaceScheduled: true } + : {}), + }), + cache: "no-store", + } + ); + if (response.status === 409) { + const text = await response.text(); + let body: DashboardScheduledChangeConflict | null = null; + try { + body = text + ? (JSON.parse(text) as DashboardScheduledChangeConflict) + : null; + } catch { + body = null; + } + if (body?.code === "scheduled_change_exists") { + throw new PmtHouseError(body.error || "Scheduled plan change exists", { + status: 409, + code: "scheduled_change_exists", + details: body, + }); + } } - if (body?.code === "scheduled_change_exists") { - throw new PmtHouseError(body.error || "Scheduled plan change exists", { - status: 409, - code: "scheduled_change_exists", - details: body, - }); + return await readPymthouseResponse(response); + } catch (error) { + if (isOwnerWalletMutationError(error)) { + return changeOwnerWalletPlan({ planId: input.planId }); } + throw error; } - return readPymthouseResponse(response); } type UserSubscriptionWithLivePlan = UserSubscriptionResponse & { @@ -177,9 +228,13 @@ export function mapDashboardUserSubscription( const livePlanId = result.livePlan?.id?.trim() || null; const livePlanName = result.livePlan?.name?.trim() || null; return { - planId: sub?.planId?.trim() || livePlanId || pending?.planId?.trim() || null, + planId: + sub?.planId?.trim() || livePlanId || pending?.planId?.trim() || null, planName: - sub?.planName?.trim() || livePlanName || pending?.planName?.trim() || null, + sub?.planName?.trim() || + livePlanName || + pending?.planName?.trim() || + null, status: sub?.status?.trim() || (pending ? "canceled" : null), subscriptionId: sub?.id?.trim() || pending?.subscriptionId?.trim() || null, currentPeriodEnd: @@ -200,6 +255,9 @@ export function mapDashboardUserSubscription( export async function getDashboardUserSubscription( externalUserId: string ): Promise { + if ((await resolveSessionBillingRail(externalUserId)) === "owner") { + return mapOwnerUserSubscription(await getOwnerSubscriptionStatus()); + } const client = createPmtHouseClientForPublicApp(readPublicClientId()); const result = (await client.getUserSubscription( externalUserId @@ -211,6 +269,10 @@ export async function cancelDashboardUserSubscription( externalUserId: string, opts?: { timing?: string; effectiveAt?: string } ): Promise { + if ((await resolveSessionBillingRail(externalUserId)) === "owner") { + await cancelOwnerWalletPlan(); + return; + } const client = createPmtHouseClientForPublicApp(readPublicClientId()); await client.cancelUserSubscription(externalUserId, { confirm: true, @@ -222,6 +284,10 @@ export async function cancelDashboardUserSubscription( export async function resumeDashboardUserSubscription( externalUserId: string ): Promise { + if ((await resolveSessionBillingRail(externalUserId)) === "owner") { + await resumeOwnerWalletPlan(); + return; + } const client = createPmtHouseClientForPublicApp(readPublicClientId()); await client.resumeUserSubscription(externalUserId, { confirm: true }); } @@ -327,8 +393,7 @@ async function walletFetch( const method = init?.method ?? "GET"; const body = - init?.body || - (externalUserId && (method === "POST" || method === "PATCH")) + init?.body || (externalUserId && (method === "POST" || method === "PATCH")) ? { ...(init?.body ?? {}), ...(externalUserId && (method === "POST" || method === "PATCH") diff --git a/lib/console/pymthouse-me-billing-bff.ts b/lib/console/pymthouse-me-billing-bff.ts index b747f7f..1ec7ef4 100644 --- a/lib/console/pymthouse-me-billing-bff.ts +++ b/lib/console/pymthouse-me-billing-bff.ts @@ -16,6 +16,7 @@ import { mintEndUserAccessToken, } from "@/lib/console/pymthouse-bff"; import { readPublicClientId } from "@/lib/console/pymthouse-http"; +import { resolveSessionBillingRail } from "@/lib/console/pymthouse-owner-billing-bff"; import type { DashboardOwnerWallet, DashboardWalletInvoice, @@ -83,6 +84,14 @@ export async function readSessionMeBilling(input: { externalUserId: string; email?: string; }): Promise { + const rail = await resolveSessionBillingRail( + input.externalUserId, + input.email + ); + if (rail === "owner") { + return { mode: "owner_rollup", code: "merchant_billing_required" }; + } + const accessToken = await mintEndUserAccessToken( input.externalUserId, input.email @@ -94,16 +103,21 @@ export async function readSessionMeBilling(input: { const client = createPmtHouseClientForPublicApp(readPublicClientId()); - const [stateResult, walletResult, subscriptionResult, pmResult, invoiceResult] = - await Promise.all([ - readMerchantPiece(() => client.getMeBillingState(accessToken)), - readMerchantPiece(() => client.getMeBillingWallet(accessToken)), - readMerchantPiece(() => client.getMeBillingSubscription(accessToken)), - readMerchantPiece(() => client.getMeBillingPaymentMethods(accessToken)), - readMerchantPiece(() => - client.getMeBillingInvoices(accessToken, { pageSize: 20 }) - ), - ]); + const [ + stateResult, + walletResult, + subscriptionResult, + pmResult, + invoiceResult, + ] = await Promise.all([ + readMerchantPiece(() => client.getMeBillingState(accessToken)), + readMerchantPiece(() => client.getMeBillingWallet(accessToken)), + readMerchantPiece(() => client.getMeBillingSubscription(accessToken)), + readMerchantPiece(() => client.getMeBillingPaymentMethods(accessToken)), + readMerchantPiece(() => + client.getMeBillingInvoices(accessToken, { pageSize: 20 }) + ), + ]); if ( stateResult === "rollup" || diff --git a/lib/console/pymthouse-owner-billing-bff.ts b/lib/console/pymthouse-owner-billing-bff.ts new file mode 100644 index 0000000..71fbadd --- /dev/null +++ b/lib/console/pymthouse-owner-billing-bff.ts @@ -0,0 +1,228 @@ +import "server-only"; + +import { + PmtHouseError, + readAccessTokenBillingMode, +} from "@pymthouse/builder-sdk"; + +import { mintEndUserAccessToken } from "@/lib/console/pymthouse-bff"; +import type { + DashboardSubscriptionChange, + DashboardUserSubscription, +} from "@/lib/console/pymthouse-billing"; +import { + pymthouseAppsOrigin, + readM2mAuthHeader, + readPublicClientId, + readPymthouseResponse, +} from "@/lib/console/pymthouse-http"; +import { + isOwnerStarterPlanKey, + isOwnerWalletPlanKey, + OWNER_WALLET_NOT_APP_USER, +} from "@/lib/console/owner-billing-rail"; + +export { + mapOwnerCatalogPlans, + mapOwnerUserSubscription, +} from "@/lib/console/owner-billing-rail"; + +export type SessionBillingRail = "owner" | "retail"; + +type AppUserSubscriptionWire = { + subscription?: { + id?: string | null; + status?: string | null; + planId?: string | null; + planName?: string | null; + openmeterPlanKey?: string | null; + currentPeriodEnd?: string | null; + } | null; + pendingCancel?: { + subscriptionId: string; + planId?: string | null; + planKey?: string | null; + planName?: string | null; + effectiveAt?: string | null; + } | null; + timingOptions?: DashboardUserSubscription["timingOptions"]; +}; + +export type OwnerPaidTierPublic = { + id: string; + key: string; + name: string; + description: string | null; + monthlyFeeUsd: string; + includedUsdMicros: string; + overageRateUsd: string | null; + sortOrder: number; + active: boolean; +}; + +export type OwnerSubscriptionSwitchingStatus = { + ownerUserId: string; + hasChargeablePaymentMethod: boolean | null; + livePaidPlanKey: string | null; + eligibleForPaidUpgrade: boolean; + canChangePaidPlan: boolean; + pendingDowngrade: { + subscriptionId?: string; + planId?: string | null; + planKey?: string | null; + planName?: string | null; + effectiveAt?: string | null; + } | null; + subscriptions: Array<{ + subscriptionId: string; + status: string; + planName: string; + openMeterPlanKey: string | null; + activeFrom: string | null; + activeTo: string | null; + }>; +}; + +type OwnerPaidMutationResult = { + openmeterSubscriptionId?: string; + planKey?: string; + effectiveAt?: string | null; + alreadyPaid?: boolean; + alreadyStarter?: boolean; + alreadyScheduled?: boolean; +}; + +async function ownerBillingFetch( + path: string, + init?: { method?: string; body?: Record } +): Promise { + const publicClientId = readPublicClientId(); + const method = init?.method ?? "GET"; + const response = await fetch( + `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/billing${path}`, + { + method, + headers: { + Authorization: readM2mAuthHeader(), + Accept: "application/json", + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + ...(init?.body ? { body: JSON.stringify(init.body) } : {}), + cache: "no-store", + } + ); + return readPymthouseResponse(response); +} + +async function fetchM2mUserSubscription( + externalUserId: string +): Promise { + const publicClientId = readPublicClientId(); + const response = await fetch( + `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(externalUserId)}/subscription`, + { + method: "GET", + headers: { + Authorization: readM2mAuthHeader(), + Accept: "application/json", + }, + cache: "no-store", + } + ); + return readPymthouseResponse(response); +} + +export function isOwnerWalletMutationError(error: unknown): boolean { + if (!(error instanceof PmtHouseError)) return false; + if (error.code === OWNER_WALLET_NOT_APP_USER) return true; + const details = error.details; + if (details && typeof details === "object" && "code" in details) { + return (details as { code?: unknown }).code === OWNER_WALLET_NOT_APP_USER; + } + return error.message.includes("cannot target the owner wallet"); +} + +/** + * Owner_rollup end-users share the app owner's OpenMeter customer. Retail + * plan APIs reject those subjects; use Owner Paid M2M instead. + */ +export async function resolveSessionBillingRail( + externalUserId: string, + email?: string +): Promise { + try { + const sub = await fetchM2mUserSubscription(externalUserId); + const liveKey = sub.subscription?.openmeterPlanKey?.trim() ?? ""; + if (isOwnerWalletPlanKey(liveKey)) return "owner"; + } catch { + // Fall through to the minted JWT claim. + } + + try { + const accessToken = await mintEndUserAccessToken(externalUserId, email); + if (readAccessTokenBillingMode(accessToken) === "owner_rollup") { + return "owner"; + } + } catch { + // Mint can fail for a brand-new user; default to retail catalog. + } + return "retail"; +} + +export async function listOwnerPaidTiers(): Promise { + const body = await ownerBillingFetch<{ tiers?: OwnerPaidTierPublic[] }>( + "/tiers" + ); + return (body.tiers ?? []).filter((tier) => tier.active !== false); +} + +export async function getOwnerSubscriptionStatus(): Promise { + return ownerBillingFetch("/subscription"); +} + +function toSubscriptionChange( + result: OwnerPaidMutationResult, + planId: string +): DashboardSubscriptionChange { + return { + subscriptionId: result.openmeterSubscriptionId?.trim() || "", + planId: result.planKey?.trim() || planId, + effectiveAt: result.effectiveAt?.trim() || null, + timing: result.effectiveAt ? "next_billing_cycle" : "immediate", + }; +} + +export async function changeOwnerWalletPlan(input: { + planId: string; +}): Promise { + const planId = input.planId.trim(); + if (isOwnerStarterPlanKey(planId)) { + const result = await ownerBillingFetch( + "/subscription", + { method: "DELETE", body: { confirm: true } } + ); + return toSubscriptionChange(result, planId); + } + const result = await ownerBillingFetch( + "/subscription", + { + method: "PUT", + body: { planKey: planId, confirm: true }, + } + ); + return toSubscriptionChange(result, planId); +} + +export async function cancelOwnerWalletPlan(): Promise { + await ownerBillingFetch("/subscription", { + method: "DELETE", + body: { confirm: true }, + }); +} + +export async function resumeOwnerWalletPlan(): Promise { + await ownerBillingFetch("/subscription/pending-change", { + method: "DELETE", + body: { confirm: true }, + }); +} diff --git a/lib/console/useBillingPlans.ts b/lib/console/useBillingPlans.ts index 733d9a9..868bf26 100644 --- a/lib/console/useBillingPlans.ts +++ b/lib/console/useBillingPlans.ts @@ -106,7 +106,10 @@ export function useBillingPlans(enabled: boolean) { subscriptionId?: string; error?: string; }>(response); - if (!response.ok || !body.checkoutUrl) { + if (!response.ok) { + throw new Error(body.error ?? `Subscribe failed (${response.status})`); + } + if (!body.checkoutUrl && !body.subscriptionId) { throw new Error(body.error ?? `Subscribe failed (${response.status})`); } return {