From 5eb16a5cd78c700cea95070a2d682a749ed1f1b3 Mon Sep 17 00:00:00 2001 From: Dhwanksh Katara Date: Wed, 14 Oct 2026 17:45:12 +0000 Subject: [PATCH 1/2] feat(auth): connect Developer Portal to shared web login --- .env.example | 6 ++ README.md | 12 +++ app/api/account/callback/route.ts | 48 ++++++++++++ app/api/account/start/route.ts | 36 +++++++++ app/auth/login/login-form.tsx | 91 +--------------------- app/auth/login/page.tsx | 9 ++- app/auth/signup/page.tsx | 6 +- app/auth/signup/signup-form.tsx | 123 +----------------------------- components/kastrick-login.tsx | 33 ++++++++ lib/kastrick-login.ts | 98 ++++++++++++++++++++++++ scripts/kastrick-login-smoke.cjs | 61 +++++++++++++++ scripts/verify-auth-contract.mjs | 19 ++--- 12 files changed, 312 insertions(+), 230 deletions(-) create mode 100644 app/api/account/callback/route.ts create mode 100644 app/api/account/start/route.ts create mode 100644 components/kastrick-login.tsx create mode 100644 lib/kastrick-login.ts create mode 100644 scripts/kastrick-login-smoke.cjs diff --git a/.env.example b/.env.example index 84cd4d2..ca931a2 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,9 @@ KASTRICK_UAC_USERNAME_INDEX_KEY=replace-with-one-stable-random-32-plus-character # from the global keys above. DEVELOPER_PORTAL_UAC_EMAIL_INDEX_KEY= DEVELOPER_PORTAL_UAC_USERNAME_INDEX_KEY= + +# Shared backend-owned web login. Use the trusted account/publisher origin. +KASTRICK_LOGIN_API_URL=https://your-kastrick-accounts.example.com +DEVELOPER_PORTAL_LOGIN_REDIRECT_URI=https://rbe-dev.vercel.app/api/account/callback +# Dedicated credential matching this service's registered clientTokenEnv value. +DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN=replace-with-at-least-32-random-characters diff --git a/README.md b/README.md index b38caca..4fdc932 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ # RBE Developer Client + +Web login/signup uses the shared backend-owned Kastrick account UI. Configure the +server-only `KASTRICK_LOGIN_API_URL`, `DEVELOPER_PORTAL_LOGIN_REDIRECT_URI`, and +`DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN` from `.env.example`, and register the exact +callback URL with kastrick-backend. See its `doc/kastrick-web-login.md` for the API +and future-service onboarding contract. + +The callback verifies browser state and PKCE, issues the existing HttpOnly portal +session cookie, and clears an old RPX cookie when the account changes. RPX browser +authorization preserves its local return path through signup/login. Existing UAC +proxy settings still support session validation and RPX exchange. Desktop account +selection and the KA dashboard are separate next-task work. diff --git a/app/api/account/callback/route.ts b/app/api/account/callback/route.ts new file mode 100644 index 0000000..788076c --- /dev/null +++ b/app/api/account/callback/route.ts @@ -0,0 +1,48 @@ +import { DEVELOPER_RPX_COOKIE } from "@/lib/rpx-web"; +import { NextRequest, NextResponse } from "next/server"; +import { loginApi, loginConfig, readLoginTransaction, DEVELOPER_SERVICE_ID, DEVELOPER_SESSION_COOKIE, transactionCookie, transactionCookieOptions, validLoginState } from "@/lib/kastrick-login"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const state = request.nextUrl.searchParams.get("state"); + const validState = validLoginState(state); + const transaction = validState ? readLoginTransaction(request.cookies.get(transactionCookie(state))?.value) : undefined; + let returnOrigin = request.nextUrl.origin; + let response: NextResponse; + try { + // Use the deployment-pinned origin for all redirects after configuration + // resolves, so forwarded Host headers cannot choose a return destination. + returnOrigin = new URL(loginConfig().redirectUri).origin; + if (!transaction) throw new Error("invalid_state"); + if (request.nextUrl.searchParams.has("error")) throw new Error("cancelled"); + const code = request.nextUrl.searchParams.get("code"); + if (!code || !/^[a-f0-9]{64}$/.test(code)) throw new Error("invalid_code"); + const result = await loginApi("exchange", { code, codeVerifier: transaction.verifier }); + const token = result.body.sessionToken; + const expiresAt = result.body.expiresAt; + const now = Math.floor(Date.now() / 1000); + if (!result.ok || result.body.serviceId !== DEVELOPER_SERVICE_ID || typeof token !== "string" || !token || token.length > 4096 || typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt <= now) throw new Error("exchange_failed"); + response = NextResponse.redirect(new URL(transaction.next, returnOrigin), 303); + // Clear an old RPX credential when switching KA accounts. Subsequent + // package requests exchange the new service session for the new owner. + response.cookies.set(DEVELOPER_RPX_COOKIE, "", { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 0 }); + response.cookies.set(DEVELOPER_SESSION_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: Math.min(expiresAt - now, 30 * 24 * 60 * 60), + }); + } catch (error) { + const target = new URL("/auth/login", returnOrigin); + target.searchParams.set("error", error instanceof Error && error.message === "cancelled" ? "cancelled" : "connection_failed"); + if (transaction) target.searchParams.set("next", transaction.next); + response = NextResponse.redirect(target, 303); + } + if (validState) response.cookies.set(transactionCookie(state), "", { ...transactionCookieOptions, maxAge: 0 }); + response.headers.set("Cache-Control", "no-store"); + response.headers.set("Referrer-Policy", "no-referrer"); + return response; +} diff --git a/app/api/account/start/route.ts b/app/api/account/start/route.ts new file mode 100644 index 0000000..82fb0e3 --- /dev/null +++ b/app/api/account/start/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loginApi, loginConfig, newLoginTransaction, transactionCookie, transactionCookieOptions } from "@/lib/kastrick-login"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + // A service login must be initiated deliberately in this browser, rather + // than by a third-party form creating a connection transaction. + if (request.headers.get("origin") !== request.nextUrl.origin) { + return NextResponse.json({ ok: false, message: "Reload Developer Portal before signing in." }, { status: 403 }); + } + let payload: { mode?: unknown; next?: unknown }; + try { + if (Number(request.headers.get("content-length") || 0) > 2048) throw new Error("large request"); + const text = await request.text(); + if (Buffer.byteLength(text, "utf8") > 2048) throw new Error("large request"); + payload = JSON.parse(text); + if (!payload || (payload.mode !== "login" && payload.mode !== "signup")) throw new Error("invalid mode"); + } catch { + return NextResponse.json({ ok: false, message: "Choose Sign in or Create account." }, { status: 400 }); + } + try { + const { state, challenge, transaction } = newLoginTransaction(payload.next); + const result = await loginApi("start", { mode: payload.mode, state, codeChallenge: challenge }); + if (!result.ok || typeof result.body.authorizationUrl !== "string") throw new Error("login start failed"); + const url = new URL(result.body.authorizationUrl); + const config = loginConfig(); + if (url.origin !== config.authority.origin || url.pathname !== "/login" || url.username || url.password || url.hash) throw new Error("invalid authorization URL"); + const response = NextResponse.json({ ok: true, authorizationUrl: url.href }, { headers: { "Cache-Control": "no-store" } }); + response.cookies.set(transactionCookie(state), JSON.stringify(transaction), transactionCookieOptions); + return response; + } catch { + return NextResponse.json({ ok: false, message: "Kastrick sign-in is temporarily unavailable. Please try again shortly." }, { status: 503, headers: { "Cache-Control": "no-store" } }); + } +} diff --git a/app/auth/login/login-form.tsx b/app/auth/login/login-form.tsx index 5a788fb..34bfab9 100644 --- a/app/auth/login/login-form.tsx +++ b/app/auth/login/login-form.tsx @@ -1,92 +1,5 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { FormEvent, useState } from "react"; -import styles from "../auth.module.css"; - -function messageFor(error: string): string { - if (error === "invalid_credentials") return "Email or password is incorrect."; - if (error === "rate_limited") return "Too many sign-in attempts. Try again in a moment."; - if (error === "invalid_email") return "Enter a valid email address."; - if (error === "invalid_password") return "Password must be at least 8 characters."; - if (error === "uac_unavailable") return "Developer authentication is temporarily unavailable."; - return "Could not sign in. Please try again."; -} - -function safeNextPath(value?: string): string { - return value && value.startsWith("/") && !value.startsWith("//") ? value : "/dash"; -} +import { KastrickLogin } from "@/components/kastrick-login"; export function LoginForm({ nextPath }: { nextPath?: string }) { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [busy, setBusy] = useState(false); - const destination = safeNextPath(nextPath); - const signupHref = destination === "/dash" - ? "/auth/signup" - : `/auth/signup?next=${encodeURIComponent(destination)}`; - - async function submit(event: FormEvent) { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(""); - - try { - const response = await fetch("/api/auth/login", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - const result = await response.json() as { ok?: boolean; error?: string }; - if (!response.ok || result.ok !== true) { - setError(messageFor(result.error || "authentication_failed")); - return; - } - router.replace(destination); - router.refresh(); - } catch { - setError("Could not reach Developer authentication. Please try again."); - } finally { - setBusy(false); - } - } - - return ( -
- - setEmail(event.target.value)} - required - disabled={busy} - /> - - setPassword(event.target.value)} - minLength={8} - required - disabled={busy} - /> - {error ?

{error}

: null} - -

New here? Create a developer account

-
- ); + return ; } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 8fbf71d..1c087fc 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -1,16 +1,17 @@ import { LoginForm } from "./login-form"; -type LoginSearchParams = Promise<{ next?: string | string[] }>; +type LoginSearchParams = Promise<{ next?: string | string[]; error?: string }>; export default async function LoginPage({ searchParams }: { searchParams: LoginSearchParams }) { const params = await searchParams; const nextPath = Array.isArray(params.next) ? params.next[0] : params.next; return (
-
RRBE Developer
- Developer account +
KKASTRICK | Developer Portal
+ Kastrick Account

Sign in.

-

Use your global UAC email and password to manage packages, deployments and RPX access.

+

Use your Kastrick account to manage packages, deployments and RPX access.

+ {params.error ?

{params.error === "cancelled" ? "Sign-in cancelled. Connect whenever you’re ready." : "We couldn’t complete your connection. Start sign-in again."}

: null}
); diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index d67b8ef..141c6ca 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -7,10 +7,10 @@ export default async function SignupPage({ searchParams }: { searchParams: Signu const nextPath = Array.isArray(params.next) ? params.next[0] : params.next; return (
-
RRBE Developer
- Developer account +
KKASTRICK | Developer Portal
+ Kastrick Account

Create account.

-

Create one global UAC identity for Developer Portal, Engine Studio and future Kastrick services.

+

Create one Kastrick account for Developer Portal, Engine Studio and other Kastrick services.

); diff --git a/app/auth/signup/signup-form.tsx b/app/auth/signup/signup-form.tsx index 04b67ef..514f487 100644 --- a/app/auth/signup/signup-form.tsx +++ b/app/auth/signup/signup-form.tsx @@ -1,124 +1,5 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { FormEvent, useState } from "react"; -import styles from "../auth.module.css"; - -function messageFor(error: string): string { - if (error === "account_exists") return "This UAC account already exists."; - if (error === "identity_conflict") return "That username is already taken."; - if (error === "invalid_email") return "Enter a valid email address."; - if (error === "invalid_username") return "Use 3–32 letters, numbers, dots, underscores or hyphens."; - if (error === "invalid_password") return "Password must be at least 8 characters."; - if (error === "signup_busy") return "Account creation is busy. Try again in a moment."; - if (error === "rate_limited") return "Too many attempts. Try again in a moment."; - return "Could not create the account. Please try again."; -} - -function safeNextPath(value?: string): string { - return value && value.startsWith("/") && !value.startsWith("//") ? value : "/dash"; -} +import { KastrickLogin } from "@/components/kastrick-login"; export function SignupForm({ nextPath }: { nextPath?: string }) { - const router = useRouter(); - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [accountExists, setAccountExists] = useState(false); - const [busy, setBusy] = useState(false); - const destination = safeNextPath(nextPath); - const loginHref = destination === "/dash" - ? "/auth/login" - : `/auth/login?next=${encodeURIComponent(destination)}`; - - async function submit(event: FormEvent) { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(""); - setAccountExists(false); - - try { - const response = await fetch("/api/auth/signup", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ username, email, password }), - }); - const result = await response.json() as { ok?: boolean; error?: string }; - if (!response.ok || result.ok !== true) { - const code = result.error || "signup_failed"; - setAccountExists(code === "account_exists"); - setError(messageFor(code)); - return; - } - router.replace(destination); - router.refresh(); - } catch { - setError("Could not reach Developer authentication. Please try again."); - } finally { - setBusy(false); - } - } - - return ( -
- - setUsername(event.target.value)} - minLength={3} - maxLength={32} - pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,31}" - required - disabled={busy} - /> -

3–32 characters. Letters, numbers, dots, underscores and hyphens.

- - - setEmail(event.target.value)} - required - disabled={busy} - /> - - - setPassword(event.target.value)} - minLength={8} - required - disabled={busy} - /> - {error ?

{error}

: null} - {accountExists ? ( -

- You already have a UAC account. Log in instead? -

- ) : null} - - {!accountExists ? ( -

Already have an account? Sign in

- ) : null} -
- ); + return ; } diff --git a/components/kastrick-login.tsx b/components/kastrick-login.tsx new file mode 100644 index 0000000..4f8fa7d --- /dev/null +++ b/components/kastrick-login.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import styles from "../app/auth/auth.module.css"; + +/** Credential entry is rendered by the shared Kastrick account authority, + * with an explicit Developer Portal connection after the user signs in. */ +export function KastrickLogin({ mode, nextPath }: { mode: "login" | "signup"; nextPath?: string }) { + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + async function start() { + if (busy) return; + setBusy(true); + setMessage(""); + try { + const response = await fetch("/api/account/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, next: nextPath }) }); + const body = await response.json(); + if (!response.ok || body.ok !== true || typeof body.authorizationUrl !== "string") throw new Error(body.message || "Couldn’t open Kastrick sign-in."); + setBusy(false); + window.location.assign(body.authorizationUrl); + } catch (error) { setMessage(error instanceof Error ? error.message : "Couldn’t open Kastrick sign-in."); setBusy(false); } + } + const other = mode === "login" ? "/auth/signup" : "/auth/login"; + return ( +
+ + {message ?

{message}

: null} +

Use one Kastrick account across services. This sign-in connects only Developer Portal.

+ {mode === "login" ? "Need an account? Create one" : "Already have a Kastrick account? Sign in"} +
+ ); +} diff --git a/lib/kastrick-login.ts b/lib/kastrick-login.ts new file mode 100644 index 0000000..2e6e380 --- /dev/null +++ b/lib/kastrick-login.ts @@ -0,0 +1,98 @@ +import { createHash, randomBytes } from "node:crypto"; + +export const DEVELOPER_SERVICE_ID = "developer_portal"; +export const DEVELOPER_SESSION_COOKIE = "rbe_developer_session"; +export const LOGIN_TTL_SECONDS = 600; + +export interface LoginTransaction { + verifier: string; + next: string; + expiresAt: number; +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error("Kastrick login is not configured for Developer Portal."); + return value; +} + +/** Deployments pin the callback and login authority; neither comes from Host + * headers, browser input, or an upstream response's arbitrary destination. */ +export function loginConfig() { + const authority = new URL(requiredEnv("KASTRICK_LOGIN_API_URL")); + const redirectUri = new URL(requiredEnv("DEVELOPER_PORTAL_LOGIN_REDIRECT_URI")); + for (const url of [authority, redirectUri]) { + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash) { + throw new Error("Kastrick login URLs must use HTTPS and contain no credentials or query parameters."); + } + } + if (authority.pathname !== "/" || redirectUri.pathname !== "/api/account/callback") { + throw new Error("Kastrick login authority or callback URL is invalid."); + } + const clientToken = requiredEnv("DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN"); + if (clientToken.length < 32) throw new Error("Kastrick login client token must be at least 32 characters."); + return { authority, redirectUri: redirectUri.href, clientToken }; +} + +export function safeReturnPath(value: unknown): string { + if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/dash"; + const parsed = new URL(value, "https://portal.invalid"); + if (parsed.origin !== "https://portal.invalid" || parsed.pathname.startsWith("/api/") || parsed.pathname.startsWith("/auth/login")) return "/dash"; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + +export function newLoginTransaction(next: unknown, now = Date.now()) { + const state = randomBytes(32).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + return { + state, + challenge: createHash("sha256").update(verifier).digest("base64url"), + transaction: { verifier, next: safeReturnPath(next), expiresAt: Math.floor(now / 1000) + LOGIN_TTL_SECONDS }, + }; +} + +export function validLoginState(value: string | null): value is string { + return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value); +} + +export function transactionCookie(state: string): string { + return `kastrick-login-${state}`; +} + +export function readLoginTransaction(value: string | undefined, now = Date.now()): LoginTransaction | undefined { + try { + const transaction = JSON.parse(value ?? "") as LoginTransaction; + if (!transaction || typeof transaction.verifier !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(transaction.verifier) || !Number.isSafeInteger(transaction.expiresAt) || transaction.expiresAt <= Math.floor(now / 1000)) return undefined; + return { ...transaction, next: safeReturnPath(transaction.next) }; + } catch { + return undefined; + } +} + +export const transactionCookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/api/account", + maxAge: LOGIN_TTL_SECONDS, +}; + +/** Only the service server holds its client credential and PKCE verifier. */ +export async function loginApi(action: "start" | "exchange", body: Record) { + const config = loginConfig(); + const response = await fetch(new URL(`/v1/login/${action}`, config.authority), { + method: "POST", + headers: { Authorization: `Bearer ${config.clientToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, redirectUri: config.redirectUri }), + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + let result: Record = {}; + try { + const parsed: unknown = await response.json(); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) result = parsed as Record; + } catch { /* Invalid upstream responses remain failures without exposing raw HTML. */ } + return { ok: response.ok && result.ok === true, status: response.status, body: result }; +} diff --git a/scripts/kastrick-login-smoke.cjs b/scripts/kastrick-login-smoke.cjs new file mode 100644 index 0000000..5c64dbe --- /dev/null +++ b/scripts/kastrick-login-smoke.cjs @@ -0,0 +1,61 @@ +// Test the real route handlers with an isolated upstream stub. The short +// browser return code must never become a browser-readable account bearer. +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); +const Module = require("node:module"); +const root = path.resolve(__dirname, ".."); +const originalLoad = Module._load; +Module._load = function (name, parent, isMain) { + if (name === "server-only") return {}; + if (name.startsWith("@/")) name = path.join(root, name.slice(2)); + return originalLoad.call(this, name, parent, isMain); +}; +require.extensions[".ts"] = (module, filename) => { + module._compile(ts.transpileModule(fs.readFileSync(filename, "utf8"), { + fileName: filename, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, + }).outputText, filename); +}; +const assert = require("node:assert/strict"); +const { NextRequest } = require("next/server"); +const { POST: start } = require("../app/api/account/start/route.ts"); +const { GET: callback } = require("../app/api/account/callback/route.ts"); +const { newLoginTransaction, safeReturnPath, transactionCookie } = require("../lib/kastrick-login.ts"); +process.env.KASTRICK_LOGIN_API_URL = "http://localhost:4100"; +process.env.DEVELOPER_PORTAL_LOGIN_REDIRECT_URI = "http://localhost:3200/api/account/callback"; +process.env.DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN = "test-only-client-credential-with-32-characters"; +const origin = "http://localhost:3200"; +const realFetch = global.fetch; +let calls = []; +let body = { ok: true, authorizationUrl: "http://localhost:4100/login?request=test" }; +global.fetch = async (url, init) => { + calls.push({ url: String(url), payload: JSON.parse(init.body) }); + return new Response(JSON.stringify(body), { status: body.ok ? 200 : 400 }); +}; +async function main() { + assert.equal(safeReturnPath("/\\evil.example"), "/dash"); + const next = "/auth/authorize?code=ABCD1234"; + const started = await start(new NextRequest(`${origin}/api/account/start`, { method: "POST", headers: { origin, "content-type": "application/json" }, body: JSON.stringify({ mode: "login", next }) })); + assert.equal(started.status, 200); + assert.equal(calls[0].payload.serviceId, "developer_portal"); + const { state, transaction } = newLoginTransaction(next); + const cookie = `${transactionCookie(state)}=${encodeURIComponent(JSON.stringify(transaction))}`; + const url = `${origin}/api/account/callback?state=${state}&code=${"a".repeat(64)}`; + calls = []; + await callback(new NextRequest(url)); + assert.equal(calls.length, 0); + body = { ok: true, serviceId: "developer_portal", sessionToken: "test-only-service-session", expiresAt: Math.floor(Date.now() / 1000) + 1000 }; + const result = await callback(new NextRequest(url, { headers: { cookie } })); + assert.equal(result.headers.get("location"), `${origin}${next}`); + assert.equal(result.cookies.get("rbe_developer_session").value, "test-only-service-session"); + assert.equal(result.cookies.get("rbe_developer_rpx").maxAge, 0); + assert.equal(result.cookies.get(transactionCookie(state)).maxAge, 0); + assert.equal(calls[0].payload.codeVerifier, transaction.verifier); + body = { ok: false, error: "invalid_grant" }; + const replay = await callback(new NextRequest(url, { headers: { cookie } })); + assert.equal(replay.cookies.get("rbe_developer_session"), undefined); + assert.match(replay.headers.get("location"), /connection_failed/); + console.log("Developer Portal shared web-login state, PKCE, RPX return and account-switch checks passed."); +} +main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => { global.fetch = realFetch; Module._load = originalLoad; }); diff --git a/scripts/verify-auth-contract.mjs b/scripts/verify-auth-contract.mjs index a66b502..afa95b6 100644 --- a/scripts/verify-auth-contract.mjs +++ b/scripts/verify-auth-contract.mjs @@ -7,6 +7,8 @@ const files = { loginForm: "app/auth/login/login-form.tsx", signupForm: "app/auth/signup/signup-form.tsx", uac: "lib/uac.ts", + hostedLogin: "components/kastrick-login.tsx", + callback: "app/api/account/callback/route.ts", }; const source = Object.fromEntries( @@ -57,19 +59,10 @@ for (const forbidden of [ } } -requireText("loginForm", [ - "function safeNextPath", - "nextPath", - "router.replace(destination)", - "/auth/signup?next=", -]); -requireText("signupForm", [ - "function safeNextPath", - "nextPath", - "router.replace(destination)", - "/auth/login?next=", - 'code === "account_exists"', -]); +requireText("loginForm", [' Date: Wed, 14 Oct 2026 18:31:30 +0000 Subject: [PATCH 2/2] feat(portal): add central KA profile settings and account badge --- README.md | 19 +++++++- app/api/account/manage/route.ts | 6 +++ app/api/account/profile/route.ts | 27 +++++++++++ app/dash/account/page.tsx | 4 ++ components/KastrickProfile.module.css | 17 +++++++ components/KastrickProfile.tsx | 69 +++++++++++++++++++++++++++ components/dash-shell.tsx | 5 +- lib/ka-profile.ts | 25 ++++++++++ lib/kastrick-login.ts | 4 +- scripts/kastrick-profile-smoke.cjs | 45 +++++++++++++++++ 10 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 app/api/account/manage/route.ts create mode 100644 app/api/account/profile/route.ts create mode 100644 app/dash/account/page.tsx create mode 100644 components/KastrickProfile.module.css create mode 100644 components/KastrickProfile.tsx create mode 100644 lib/ka-profile.ts create mode 100644 scripts/kastrick-profile-smoke.cjs diff --git a/README.md b/README.md index 4fdc932..204e8cc 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,21 @@ The callback verifies browser state and PKCE, issues the existing HttpOnly porta session cookie, and clears an old RPX cookie when the account changes. RPX browser authorization preserves its local return path through signup/login. Existing UAC proxy settings still support session validation and RPX exchange. Desktop account -selection and the KA dashboard are separate next-task work. +selection uses the backend's separate public device flow. Web login keeps the +credential flow. + +Global profile editing is available at `/dash/account`. `/api/account/profile` +proxies the centrally stored KA name/avatar with the service identity and session +pinned server-side. Profile changes refresh on focus and while visible; drafts +survive errors and concurrent edits require an explicit refresh. The account +navigation badge uses that same profile. `/api/account/manage` opens the configured +central dashboard for service disconnects, account-wide managed storage, and +signout. A portal-only session may need to sign in to the authority first. + +Validation: + +```bash +npm run typecheck +node scripts/kastrick-login-smoke.cjs +node scripts/kastrick-profile-smoke.cjs +``` diff --git a/app/api/account/manage/route.ts b/app/api/account/manage/route.ts new file mode 100644 index 0000000..343767c --- /dev/null +++ b/app/api/account/manage/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { loginConfig } from "@/lib/kastrick-login"; +export function GET() { + try { return NextResponse.redirect(new URL("/account", loginConfig().authority)); } + catch { return NextResponse.json({ ok: false, message: "Kastrick Account management is temporarily unavailable." }, { status: 503 }); } +} diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts new file mode 100644 index 0000000..32d38ca --- /dev/null +++ b/app/api/account/profile/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loginApi, DEVELOPER_SESSION_COOKIE } from "@/lib/kastrick-login"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +async function dispatch(request: NextRequest, update: boolean) { + if (update && request.headers.get("origin") !== request.nextUrl.origin) return NextResponse.json({ ok: false, message: "Reload this page before saving." }, { status: 403 }); + const token = request.cookies.get(DEVELOPER_SESSION_COOKIE)?.value; + if (!token) return NextResponse.json({ ok: false, message: "Sign in to edit your Kastrick profile." }, { status: 401 }); + let edits: Record = {}; + if (update) { + try { + const text = await request.text(); if (Buffer.byteLength(text, "utf8") > 2048) throw new Error("large profile"); + const payload = JSON.parse(text); + if (!payload || typeof payload.displayName !== "string" || payload.displayName.length > 160 || typeof payload.avatarTone !== "string" || !Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) throw new Error("invalid profile"); + edits = { displayName: payload.displayName, avatarTone: payload.avatarTone, expectedRevision: payload.expectedRevision }; + } catch { return NextResponse.json({ ok: false, message: "Enter a display name and choose an avatar color." }, { status: 400 }); } + } + try { + // Ignore browser-supplied identities/tokens: the service and session come + // exclusively from deployment configuration and the HttpOnly cookie. + const result = await loginApi("profile", { action: update ? "updateProfile" : "getProfile", sessionToken: token, ...edits }); + const message = result.status === 409 ? "Your profile changed in another service. Refresh it before saving; your input has been kept." : result.status === 401 ? "Your session expired. Sign in again." : result.status === 400 ? "Use a name of 1–80 characters and choose an avatar color." : "Kastrick accounts are temporarily unavailable."; + return NextResponse.json(result.ok ? { ok: true, profile: result.body.profile, avatarUrl: result.body.avatarUrl, storage: result.body.storage } : { ok: false, message }, { status: result.ok ? 200 : result.status >= 400 ? result.status : 502, headers: { "Cache-Control": "no-store" } }); + } catch { return NextResponse.json({ ok: false, message: "Kastrick accounts are temporarily unavailable." }, { status: 503 }); } +} +export function GET(request: NextRequest) { return dispatch(request, false); } +export function PATCH(request: NextRequest) { return dispatch(request, true); } diff --git a/app/dash/account/page.tsx b/app/dash/account/page.tsx new file mode 100644 index 0000000..3e85fc2 --- /dev/null +++ b/app/dash/account/page.tsx @@ -0,0 +1,4 @@ +import { KastrickProfileEditor } from "@/components/KastrickProfile"; +export default function AccountPage() { + return
KA · Kastrick Account

Your profile, everywhere.

Profile changes sync with Engine Studio and your connected Kastrick apps.

; +} diff --git a/components/KastrickProfile.module.css b/components/KastrickProfile.module.css new file mode 100644 index 0000000..ddf18c3 --- /dev/null +++ b/components/KastrickProfile.module.css @@ -0,0 +1,17 @@ +.profile { padding: 16px; color: inherit; border: 1px solid var(--border, #51435e); border-radius: 12px; font-size: 12px; } +.profile header { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; } +.profile header img { flex: none; border-radius: 14px; } +.profile h3 { margin: 0 0 5px; font-size: 16px; } +.profile p { margin: 8px 0; line-height: 1.6; overflow-wrap: anywhere; } +.profile header p { font-size: 11px; } +.profile form { display: grid; gap: 9px; } +.profile label { font-weight: 600; margin-top: 4px; } +.profile input, .profile select { min-height: 42px; width: 100%; border: 1px solid var(--border, #51435e); border-radius: 7px; background: var(--input-bg, transparent); color: inherit; padding: 10px; font: inherit; } +.profile select option { background: #1b1525; color: #f6edff; } +.profile button { min-height: 44px; border: 1px solid #c4a7ee; border-radius: 8px; margin-top: 10px; background: #d6c0ff; color: #21132f; font: inherit; font-weight: 650; cursor: pointer; } +.profile button:disabled { opacity: .6; cursor: default; } +.profile a { display: inline-block; min-height: 44px; padding: 14px 0 0; color: inherit; text-decoration: underline; } +.profile :focus-visible { outline: 2px solid #bda1ea; outline-offset: 3px; } +.badge { display: inline-flex; align-items: center; gap: 7px; min-width: 0; } +.badge img { border-radius: 7px; flex: none; } +.badge span { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/components/KastrickProfile.tsx b/components/KastrickProfile.tsx new file mode 100644 index 0000000..1b57ef4 --- /dev/null +++ b/components/KastrickProfile.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useEffect, useId, useRef, useState } from "react"; +import { PROFILE_UPDATED_EVENT, readKastrickProfile, updateKastrickProfile, type KastrickProfileReply } from "@/lib/ka-profile"; +import styles from "./KastrickProfile.module.css"; + +/** Refresh on focus/visibility and while visible, without replacing an owner's + * unsaved draft. The draft retains its original revision for conflict checking. */ +export function KastrickProfileEditor() { + const id = useId(); + const [account, setAccount] = useState(null); + const [name, setName] = useState(""); const [tone, setTone] = useState("violet"); + const [busy, setBusy] = useState(false); const [message, setMessage] = useState("Loading your Kastrick profile…"); + const dirty = useRef(false); const saving = useRef(false); const draftRevision = useRef(0); + const refreshRef = useRef<(force?: boolean) => Promise>(async () => {}); + useEffect(() => { + const controller = new AbortController(); let refreshing = false; + async function refresh(force = false) { + if (saving.current || refreshing) return; + refreshing = true; + try { + const body = await readKastrickProfile(controller.signal); + if (controller.signal.aborted || saving.current) return; + setAccount(body); + if (!dirty.current || force) { setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false; setMessage(force ? "Profile refreshed." : ""); } + } catch (error) { if (!controller.signal.aborted) setMessage(error instanceof Error ? error.message : "Couldn’t refresh your profile."); } + finally { refreshing = false; } + } + refreshRef.current = refresh; + const update = () => { if (!document.hidden) void refresh(); }; + void refresh(); window.addEventListener("focus", update); window.addEventListener(PROFILE_UPDATED_EVENT, update); document.addEventListener("visibilitychange", update); + const timer = window.setInterval(update, 30000); + return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", update); window.removeEventListener(PROFILE_UPDATED_EVENT, update); document.removeEventListener("visibilitychange", update); }; + }, []); + async function save(event: React.FormEvent) { + event.preventDefault(); if (saving.current) return; saving.current = true; setBusy(true); + try { + const body = await updateKastrickProfile(name, tone, draftRevision.current); + setAccount(body); setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false; + setMessage("Profile saved across Kastrick. Other services refresh it when you return to them."); window.dispatchEvent(new Event(PROFILE_UPDATED_EVENT)); + } catch (error) { setMessage(error instanceof Error ? error.message : "Couldn’t save your profile."); } + finally { saving.current = false; setBusy(false); } + } + function reload() { + if (dirty.current && !window.confirm("Replace unsaved edits with your current Kastrick profile?")) return; + void refreshRef.current(true); + } + return ( +
+
{account ? Generated account avatar : null}

Kastrick Account

{account?.profile.email || "One profile across Kastrick services."}

+
void save(event)}> + { dirty.current = true; setName(event.target.value); }} maxLength={80} required disabled={busy || !account} autoComplete="nickname" /> + + +
+ {message ?

{message}

: null} + Manage connected services & storage ↗ +
+ ); +} +export function KastrickProfileBadge() { + const [account, setAccount] = useState(null); + useEffect(() => { + const controller = new AbortController(); let refreshing = false; + async function refresh() { if (refreshing || document.hidden) return; refreshing = true; try { const body = await readKastrickProfile(controller.signal); if (!controller.signal.aborted) setAccount(body); } catch { /* The service's existing session UI owns authentication failures. */ } finally { refreshing = false; } } + void refresh(); window.addEventListener("focus", refresh); window.addEventListener(PROFILE_UPDATED_EVENT, refresh); document.addEventListener("visibilitychange", refresh); const timer = window.setInterval(refresh, 30000); + return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(PROFILE_UPDATED_EVENT, refresh); document.removeEventListener("visibilitychange", refresh); }; + }, []); + return {account ? : null}{account?.profile.displayName || "Kastrick Account"}; +} diff --git a/components/dash-shell.tsx b/components/dash-shell.tsx index 4504686..45310d0 100644 --- a/components/dash-shell.tsx +++ b/components/dash-shell.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { KastrickProfileBadge } from "@/components/KastrickProfile"; import { ThemeToggle } from "@/components/theme-toggle"; const mainItems = [ @@ -11,7 +12,7 @@ const mainItems = [ ["Upload New Package", "/dash/new-package", "+"], ]; -const reserved = new Set(["packages", "deployments", "new-package"]); +const reserved = new Set(["packages", "deployments", "new-package", "account"]); export function DashShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -55,7 +56,7 @@ export function DashShell({ children }: { children: React.ReactNode }) {
Developer Console{packageName && / {packageName}}
- + ↗
{children}
diff --git a/lib/ka-profile.ts b/lib/ka-profile.ts new file mode 100644 index 0000000..a530b51 --- /dev/null +++ b/lib/ka-profile.ts @@ -0,0 +1,25 @@ +/** Browser profile requests go to the service server. No session/client token + * crosses this boundary; the shared authority stores the canonical profile. */ +export interface KastrickProfile { + userId: string; + displayName: string; + email: string | null; + avatarSeed: string; + avatarTone: string; + revision: number; +} +export interface KastrickProfileReply { profile: KastrickProfile; avatarUrl: string } +export const PROFILE_UPDATED_EVENT = "kastrick-profile-updated"; +async function profileRequest(init?: RequestInit): Promise { + const response = await fetch("/api/account/profile", { ...init, cache: "no-store" }); + const result = await response.json(); + if (!response.ok || result.ok !== true) throw new Error(result.message || "Couldn’t load your Kastrick profile."); + if (!result.profile || typeof result.profile.displayName !== "string" || !Number.isSafeInteger(result.profile.revision) || typeof result.avatarUrl !== "string") throw new Error("Kastrick returned an invalid profile."); + const avatar = new URL(result.avatarUrl); + if (!["https:", "http:"].includes(avatar.protocol) || avatar.username || avatar.password) throw new Error("Kastrick returned an invalid avatar."); + return { profile: result.profile, avatarUrl: avatar.href }; +} +export function readKastrickProfile(signal?: AbortSignal) { return profileRequest({ signal }); } +export function updateKastrickProfile(displayName: string, avatarTone: string, expectedRevision: number) { + return profileRequest({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, avatarTone, expectedRevision }) }); +} diff --git a/lib/kastrick-login.ts b/lib/kastrick-login.ts index 2e6e380..9d3fa51 100644 --- a/lib/kastrick-login.ts +++ b/lib/kastrick-login.ts @@ -79,12 +79,12 @@ export const transactionCookieOptions = { }; /** Only the service server holds its client credential and PKCE verifier. */ -export async function loginApi(action: "start" | "exchange", body: Record) { +export async function loginApi(action: "start" | "exchange" | "profile", body: Record) { const config = loginConfig(); const response = await fetch(new URL(`/v1/login/${action}`, config.authority), { method: "POST", headers: { Authorization: `Bearer ${config.clientToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, redirectUri: config.redirectUri }), + body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, ...(action === "profile" ? {} : { redirectUri: config.redirectUri }) }), cache: "no-store", redirect: "error", signal: AbortSignal.timeout(30_000), diff --git a/scripts/kastrick-profile-smoke.cjs b/scripts/kastrick-profile-smoke.cjs new file mode 100644 index 0000000..c404726 --- /dev/null +++ b/scripts/kastrick-profile-smoke.cjs @@ -0,0 +1,45 @@ +// Test the real route handlers with an isolated upstream stub. The short +// browser return code must never become a browser-readable account bearer. +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); +const Module = require("node:module"); +const root = path.resolve(__dirname, ".."); +const originalLoad = Module._load; +Module._load = function (name, parent, isMain) { + if (name === "server-only") return {}; + if (name.startsWith("@/")) name = path.join(root, name.slice(2)); + return originalLoad.call(this, name, parent, isMain); +}; +require.extensions[".ts"] = (module, filename) => { + module._compile(ts.transpileModule(fs.readFileSync(filename, "utf8"), { + fileName: filename, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, + }).outputText, filename); +}; +const assert = require("node:assert/strict"); +const { NextRequest } = require("next/server"); +const { GET, PATCH } = require("../app/api/account/profile/route.ts"); +process.env.KASTRICK_LOGIN_API_URL = "http://localhost:4100"; +process.env.DEVELOPER_PORTAL_LOGIN_REDIRECT_URI = "http://localhost:3100/api/account/callback"; +process.env.DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN = "test-only-client-credential-with-32-characters"; +const origin = "http://localhost:3100"; +const savedFetch = global.fetch; +let calls = []; +let status = 200; +global.fetch = async (url, init) => { calls.push(JSON.parse(init.body)); return new Response(JSON.stringify(status === 200 ? { ok: true, profile: { displayName: "Ada", revision: 2 }, avatarUrl: "http://localhost:4100/avatars/test" } : { ok: false, error: "profile_changed" }), { status }); }; +async function main() { + assert.equal((await GET(new NextRequest(`${origin}/api/account/profile`))).status, 401); + assert.equal(calls.length, 0); + const headers = { origin, cookie: "rbe_developer_session=server-session", "content-type": "application/json" }; + let response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Ada", avatarTone: "cyan", expectedRevision: 1, sessionToken: "attacker-session", serviceId: "other", userId: "victim" }) })); + assert.equal(response.status, 200); assert.equal(calls[0].sessionToken, "server-session"); assert.equal(calls[0].serviceId, "developer_portal"); assert.equal(calls[0].userId, undefined); assert.equal(calls[0].expectedRevision, 1); assert.deepEqual(Object.keys(calls[0]).sort(), ["action", "avatarTone", "displayName", "expectedRevision", "serviceId", "sessionToken"].sort()); + calls = []; + response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers: { ...headers, origin: "https://evil.example" }, body: "{}" })); + assert.equal(response.status, 403); assert.equal(calls.length, 0); + status = 409; + response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Draft", avatarTone: "rose", expectedRevision: 1 }) })); + assert.equal(response.status, 409); assert.match((await response.json()).message, /changed in another service/); + console.log("KA profile proxy cookie identity, CSRF origin and stale-revision checks passed."); +} +main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => global.fetch = savedFetch);