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..204e8cc 100644 --- a/README.md +++ b/README.md @@ -1 +1,30 @@ # 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 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/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/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/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/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/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/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 new file mode 100644 index 0000000..9d3fa51 --- /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" | "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, ...(action === "profile" ? {} : { 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/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); 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", ['