Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
48 changes: 48 additions & 0 deletions app/api/account/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions app/api/account/manage/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
}
27 changes: 27 additions & 0 deletions app/api/account/profile/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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); }
36 changes: 36 additions & 0 deletions app/api/account/start/route.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
}
91 changes: 2 additions & 89 deletions app/auth/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
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 (
<form className={styles.form} onSubmit={submit}>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
disabled={busy}
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Your password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
disabled={busy}
/>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<button className="button primary wide" type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</button>
<p className={styles.footer}>New here? <Link href={signupHref}>Create a developer account</Link></p>
</form>
);
return <KastrickLogin mode="login" nextPath={nextPath} />;
}
9 changes: 5 additions & 4 deletions app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="auth-card">
<div className="auth-brand"><span className="brand-mark">R</span><strong>RBE Developer</strong></div>
<span className="kicker">Developer account</span>
<div className="auth-brand"><span className="brand-mark">K</span><strong>KASTRICK | Developer Portal</strong></div>
<span className="kicker">Kastrick Account</span>
<h1>Sign in.</h1>
<p>Use your global UAC email and password to manage packages, deployments and RPX access.</p>
<p>Use your Kastrick account to manage packages, deployments and RPX access.</p>
{params.error ? <p role="status">{params.error === "cancelled" ? "Sign-in cancelled. Connect whenever you’re ready." : "We couldn’t complete your connection. Start sign-in again."}</p> : null}
<LoginForm nextPath={nextPath} />
</main>
);
Expand Down
6 changes: 3 additions & 3 deletions app/auth/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ export default async function SignupPage({ searchParams }: { searchParams: Signu
const nextPath = Array.isArray(params.next) ? params.next[0] : params.next;
return (
<main className="auth-card">
<div className="auth-brand"><span className="brand-mark">R</span><strong>RBE Developer</strong></div>
<span className="kicker">Developer account</span>
<div className="auth-brand"><span className="brand-mark">K</span><strong>KASTRICK | Developer Portal</strong></div>
<span className="kicker">Kastrick Account</span>
<h1>Create account.</h1>
<p>Create one global UAC identity for Developer Portal, Engine Studio and future Kastrick services.</p>
<p>Create one Kastrick account for Developer Portal, Engine Studio and other Kastrick services.</p>
<SignupForm nextPath={nextPath} />
</main>
);
Expand Down
Loading
Loading