Skip to content
Merged
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
17 changes: 14 additions & 3 deletions aastar-frontend/app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Layout from "@/components/Layout";
import { authAPI } from "@/lib/api";
import { kmsClient } from "@/lib/yaaa";
import { setStoredAuth } from "@/lib/auth";
import { safeReturnPath } from "@/lib/safe-return-path";
import toast from "react-hot-toast";
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";

const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);

/** Where to land after a successful sign-in when `?redirect=` is absent or unusable. */
const DEFAULT_LANDING = "/dashboard";

export default function LoginPage() {
const [loading, setLoading] = useState(false);
// Read in an effect (not during render): `window` doesn't exist while prerendering, and
// useSearchParams would force this page behind a Suspense boundary.
const [returnTo, setReturnTo] = useState(DEFAULT_LANDING);
useEffect(() => {
const safe = safeReturnPath(new URLSearchParams(window.location.search).get("redirect"));
if (safe) setReturnTo(safe);
}, []);
const [loginMode, setLoginMode] = useState<"passkey" | "otp">("passkey");
const [email, setEmail] = useState("");
const [otpEmail, setOtpEmail] = useState("");
Expand Down Expand Up @@ -54,7 +65,7 @@ export default function LoginPage() {
toast.dismiss(t);
setStoredAuth(access_token, user);
toast.success("Welcome back!");
router.push("/dashboard");
router.push(returnTo);
} catch (error: any) {
if (t) toast.dismiss(t);

Expand Down Expand Up @@ -163,7 +174,7 @@ export default function LoginPage() {
} else {
toast.success("Signed in!");
}
router.push("/dashboard");
router.push(returnTo);
} catch (error: any) {
if (t) toast.dismiss(t);
setWalletStatus(null);
Expand Down
207 changes: 207 additions & 0 deletions aastar-frontend/app/sso/start/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* cos72 SSO authorize kickoff (MV-7, cos72 half of the MyVote SSO handoff).
*
* This page is a *transition*, not a destination — there is nothing to click on the happy
* path. MyVote sends the browser here with `?redirect_uri=https://<myvote>/sso/callback`, and:
*
* 1. no cos72 session → bounce to /auth/login?redirect=<this page, params preserved>,
* so the handoff resumes automatically after sign-in;
* 2. session present → POST /sso/authorize { redirect_uri } with the cos72 JWT (MV-1);
* 3. 200 → location.replace(`${redirectUri}?code=${code}`) — MyVote then
* swaps the one-time code for an SSO token via POST /sso/exchange;
* 4. 400 / 401 /网络 → an explicit error card (never a silent dead end), with copy an
* operator can act on (the whitelist case names SSO_ALLOWED_REDIRECTS).
*
* OPEN-REDIRECT NOTE: the code is appended to `redirectUri` **as echoed back by the backend**,
* never to the raw `?redirect_uri` query value. The backend validates it fail-closed against
* SSO_ALLOWED_REDIRECTS and returns its normalized form, so an attacker-supplied redirect_uri
* can only ever produce a 400 here — this page performs no whitelist logic of its own (a
* frontend check would be both bypassable and a second source of truth).
*
* @module app/sso/start/page
*/
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import Layout from "@/components/Layout";
import { ssoAPI } from "@/lib/api";
import { isAuthenticated } from "@/lib/auth";

/** Distinguishable failure modes — each maps to its own i18n message. */
type ErrorKind =
| "missingRedirect" // no ?redirect_uri in the URL
| "notWhitelisted" // 400: redirect_uri not in SSO_ALLOWED_REDIRECTS (ops-actionable)
| "noAccount" // 400: the cos72 user has no AirAccount yet
| "unauthorized" // 401: cos72 session missing/expired/rejected
| "badRedirect" // 200 but the echoed redirectUri doesn't parse (should never happen)
| "network" // request never got a response
| "unknown"; // any other backend error

type Phase = { status: "working" } | { status: "error"; kind: ErrorKind; detail?: string };

/** The backend's whitelist rejection — matched on the env var name it names in its message. */
const WHITELIST_HINT = /SSO_ALLOWED_REDIRECTS/i;
const NO_ACCOUNT_HINT = /AirAccount/i;

export default function SsoStartPage() {
const { t } = useTranslation();
const router = useRouter();
const [phase, setPhase] = useState<Phase>({ status: "working" });
const [redirectUri, setRedirectUri] = useState("");
// React 18/19 StrictMode double-invokes effects in dev; minting a second one-time code is
// harmless (60s TTL, unused codes just expire) but pointless — run the handoff once.
const startedRef = useRef(false);

const start = useCallback(async () => {
const raw = new URLSearchParams(window.location.search).get("redirect_uri");
if (!raw) {
setPhase({ status: "error", kind: "missingRedirect" });
return;
}
setRedirectUri(raw);

if (!isAuthenticated()) {
// Preserve the whole query string so login can hand control straight back here.
const back = `/sso/start?redirect_uri=${encodeURIComponent(raw)}`;
router.replace(`/auth/login?redirect=${encodeURIComponent(back)}`);
return;
}

try {
const { data } = await ssoAPI.authorize(raw);
let target: URL;
try {
// Build on the backend-echoed (validated + normalized) URI, and use URLSearchParams so
// the `code` is appended with "?" or "&" correctly even when it already carries a query.
target = new URL(data.redirectUri);
} catch {
setPhase({ status: "error", kind: "badRedirect", detail: data.redirectUri });
return;
}
target.searchParams.set("code", data.code);
// replace(), not assign(): the SSO start URL must not sit in history — going "back" to it
// would re-mint a code and bounce the user around.
//
// REFERRER/URL boundary: the one-time code rides in MyVote's callback URL. cos72's job
// ends here, and using replace() (not assign()) already keeps the code-bearing URL out of
// THIS origin's history. Scrubbing the code from the *landed* MyVote callback URL (it must
// not linger in the address bar, history, or an onward Referer) is MyVote's callback
// responsibility — MyVote #4 does `history.replaceState` to strip `?code=` after exchange.
window.location.replace(target.toString());
} catch (error: unknown) {
const err = error as {
response?: { status?: number; data?: { message?: string | string[] } };
message?: string;
};
const status = err.response?.status;
const raw2 = err.response?.data?.message;
const detail = Array.isArray(raw2) ? raw2.join("; ") : raw2;

if (status === undefined) {
setPhase({ status: "error", kind: "network", detail: err.message });
} else if (status === 401) {
setPhase({ status: "error", kind: "unauthorized", detail });
} else if (status === 400 && detail && WHITELIST_HINT.test(detail)) {
setPhase({ status: "error", kind: "notWhitelisted", detail });
} else if (status === 400 && detail && NO_ACCOUNT_HINT.test(detail)) {
setPhase({ status: "error", kind: "noAccount", detail });
} else {
setPhase({ status: "error", kind: "unknown", detail: detail ?? err.message });
}
}
}, [router]);

useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void start();
}, [start]);

if (phase.status === "working") {
return (
<Layout>
<div className="flex items-start justify-center px-4 pt-24 pb-8 sm:px-6">
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="mx-auto mb-5 h-10 w-10 animate-spin rounded-full border-b-2 border-emerald-500" />
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("ssoStart.title")}
</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{t("ssoStart.working")}</p>
{redirectUri && (
<p className="mt-4 break-all font-mono text-xs text-gray-400 dark:text-gray-500">
{redirectUri}
</p>
)}
</div>
</div>
</Layout>
);
}

const isWhitelist = phase.kind === "notWhitelisted";
const message = isWhitelist
? t("ssoStart.error.notWhitelisted", { uri: redirectUri })
: t(`ssoStart.error.${phase.kind}`);

return (
<Layout>
<div className="flex items-start justify-center px-4 pt-24 pb-8 sm:px-6">
<div className="w-full max-w-lg rounded-2xl border border-red-200 bg-white p-8 shadow-xl dark:border-red-900 dark:bg-gray-800">
<h1 className="text-lg font-semibold text-red-700 dark:text-red-400">
{t("ssoStart.error.title")}
</h1>
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300">{message}</p>

{isWhitelist && (
<p className="mt-3 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
{t("ssoStart.error.opsHint")}
</p>
)}

{phase.detail && (
<p className="mt-4 break-all rounded-lg bg-gray-50 p-3 font-mono text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
{t("ssoStart.error.detailLabel")}: {phase.detail}
</p>
)}

<div className="mt-6 flex flex-wrap gap-3">
{phase.kind === "unauthorized" && (
<Link
href={`/auth/login?redirect=${encodeURIComponent(
`/sso/start?redirect_uri=${encodeURIComponent(redirectUri)}`
)}`}
className="rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 dark:bg-emerald-600 dark:hover:bg-emerald-500"
>
{t("ssoStart.signInAgain")}
</Link>
)}
{phase.kind === "noAccount" && (
<Link
href="/dashboard"
className="rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 dark:bg-emerald-600 dark:hover:bg-emerald-500"
>
{t("ssoStart.createAccount")}
</Link>
)}
<button
type="button"
onClick={() => router.back()}
className="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t("ssoStart.back")}
</button>
<Link
href="/dashboard"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
{t("ssoStart.home")}
</Link>
</div>
</div>
</div>
</Layout>
);
}
46 changes: 37 additions & 9 deletions aastar-frontend/components/nav/CommunityNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,29 @@ type Tab = {
ownerOnly?: boolean;
/** Module not built yet — render disabled. */
soon?: boolean;
/** Leaves cos72 (deployed elsewhere) — rendered as a plain <a>, not a next/link route. */
external?: boolean;
};

/**
* MyVote deployment (MV-7). Optional: when unset, the MyVote tab stays a disabled "即将上线"
* placeholder and no governance entry links out — nothing to click, nothing to 404.
* NEXT_PUBLIC_* is inlined at build time, so this must be a literal property access.
*/
const MYVOTE_URL = process.env.NEXT_PUBLIC_MYVOTE_URL?.trim() || "";

/**
* MyVote's own sign-in bounces the browser back to cos72 `/sso/start?redirect_uri=…`
* (see app/sso/start/page.tsx), so we just link at MyVote's root — cos72 never constructs
* MyVote's callback URL, and the redirect_uri whitelist stays owned by the backend.
*/
const TABS: Tab[] = [
{ key: "overview", label: "概览", href: "#" },
{ key: "mytask", label: "MyTask", href: "#", soon: true },
{ key: "myshop", label: "MyShop", href: "#", soon: true },
{ key: "myvote", label: "MyVote", href: "#", soon: true },
MYVOTE_URL
? { key: "myvote", label: "MyVote · 治理", href: MYVOTE_URL, external: true }
: { key: "myvote", label: "MyVote", href: "#", soon: true },
{ key: "members", label: "成员", href: "#" },
{ key: "governance", label: "治理", href: "#", ownerOnly: true },
{ key: "issue", label: "发币", href: "#", ownerOnly: true },
Expand Down Expand Up @@ -84,16 +100,28 @@ export function CommunityNav({ active }: { active?: string }) {
</li>
);
}
const linkClass = `${base} ${
isActive
? "border-b-2 border-emerald-500 font-medium text-gray-900 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`;
if (t.external) {
// Same-tab navigation is deliberate (no target="_blank"): governance is a primary
// workflow, not a side trip, and MyVote's SSO round-trip lands the user back on
// cos72 anyway. rel="noopener noreferrer" is kept as a belt-and-braces safe default
// (it also suppresses the Referer to MyVote); the lint rule that pairs
// noopener with _blank does not apply to a same-tab link. Safe exception.
return (
<li key={t.key}>
<a href={t.href} rel="noopener noreferrer" className={linkClass}>
{t.label}
</a>
</li>
);
}
return (
<li key={t.key}>
<Link
href={t.href}
className={`${base} ${
isActive
? "border-b-2 border-emerald-500 font-medium text-gray-900 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
<Link href={t.href} className={linkClass}>
{t.label}
</Link>
</li>
Expand Down
6 changes: 6 additions & 0 deletions aastar-frontend/env.local.template
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ NEXT_PUBLIC_KMS_API_KEY=<YOUR_KMS_API_KEY>
NEXT_PUBLIC_BLS_SEED_NODE=https://v1.aastar.io
NEXT_PUBLIC_CHAIN_ID=11155111

# MyVote deployment (MV-7). Optional — leave empty and the community nav shows no MyVote/治理
# entry. The SSO handoff page (/sso/start) does NOT use this: MyVote itself sends the browser
# to cos72 with ?redirect_uri=<myvote>/sso/callback, and the backend validates that value
# against SSO_ALLOWED_REDIRECTS (see aastar/.env.example).
NEXT_PUBLIC_MYVOTE_URL=

# AAStar canonical contract addresses (Sepolia 11155111)
NEXT_PUBLIC_REGISTRY_ADDRESS=0x7Ba70C5bFDb3A4d0cBd220534f3BE177fefc1788
NEXT_PUBLIC_GTOKEN_ADDRESS=0x9ceDeC089921652D050819ca5BE53765fc05aa9E
Expand Down
31 changes: 30 additions & 1 deletion aastar-frontend/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import axios from "axios";

declare module "axios" {
export interface AxiosRequestConfig {
/**
* Opt this request out of the global "401 → wipe session + bounce to /auth/login"
* response interceptor, so the caller can handle the 401 itself. Used by flows that
* must keep their own context across the login round-trip (e.g. the SSO handoff page,
* which has to preserve `?redirect_uri`).
*/
skipAuthRedirect?: boolean;
}
}

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api/v1";

const api = axios.create({
Expand All @@ -22,7 +34,7 @@ api.interceptors.request.use(config => {
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
if (error.response?.status === 401 && !error.config?.skipAuthRedirect) {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/auth/login";
Expand All @@ -31,6 +43,23 @@ api.interceptors.response.use(
}
);

// MyVote SSO handoff (MV-7 frontend half of the MV-1 backend endpoints).
// `/sso/authorize` is JWT-guarded: the cos72 session token identifies the user (the userId is
// NEVER sent in the body) and the backend mints a one-time code bound to a *whitelisted*
// redirect_uri (fail-closed against SSO_ALLOWED_REDIRECTS). The response echoes the normalized
// redirectUri — the browser must redirect to THAT value, not to the raw URL query param, which
// is what keeps this from being an open redirect.
// `skipAuthRedirect`: a 401 here must not silently dump the user on a bare /auth/login — the
// SSO start page re-enters login with a return path so the handoff can resume.
export const ssoAPI = {
authorize: (redirectUri: string) =>
api.post<{ code: string; redirectUri: string }>(
"/sso/authorize",
{ redirect_uri: redirectUri },
{ skipAuthRedirect: true }
),
};

// Generic gasless UserOperation API (Phase 0 §0.2 — the backend contract cosSend calls).
// Two-phase like transferAPI: `prepare` returns the userOpHash for the browser to sign
// via passkey (WebAuthn assertion whose challenge = userOpHash); `submit` hands the
Expand Down
Loading
Loading