v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.
A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
164
src/pages/Authorize.tsx
Normal file
164
src/pages/Authorize.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Renders the page where another gerbeur instance asks to sign the user in
|
||||
* with their account here, `/authorize`. That instance's login page sends
|
||||
* them, with its request in the query, and this page sends them back to it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type {
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
OAuthClient,
|
||||
} from "../../shared/federation.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { homeHost } from "../utils/auth/home.ts";
|
||||
import { loginPath } from "../utils/auth/next.ts";
|
||||
|
||||
/**
|
||||
* Renders `/authorize?client_id=&redirect_uri=&state=&code_challenge=&nonce=`.
|
||||
* A signed-out visitor signs in first and comes back. The page names the
|
||||
* instance asking (`GET /api/oauth/clients`) and what it will receive; once
|
||||
* the user continues, or at once if they already did before, it gets a code
|
||||
* (`POST /api/oauth/codes`) and goes back to the instance with it. An account
|
||||
* that itself comes from another instance can't be used from here.
|
||||
*/
|
||||
export function Authorize(): React.JSX.Element {
|
||||
useDocumentTitle(t`Sign in elsewhere`);
|
||||
const { user, authFetch } = useAuth();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const [client, setClient] = useState<OAuthClient | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const clientId = params.get("client_id") ?? "";
|
||||
const home = user ? homeHost(user.username) : undefined;
|
||||
|
||||
const approve = useCallback(async () => {
|
||||
setError(null);
|
||||
setLeaving(true);
|
||||
try {
|
||||
const request: AuthorizeRequest = {
|
||||
clientId,
|
||||
redirectUri: params.get("redirect_uri") ?? "",
|
||||
state: params.get("state") ?? "",
|
||||
codeChallenge: params.get("code_challenge") ?? "",
|
||||
codeChallengeMethod: params.get("code_challenge_method") ?? "",
|
||||
nonce: params.get("nonce") ?? "",
|
||||
};
|
||||
const { redirectTo } = await expectOk<AuthorizeResponse>(
|
||||
await authFetch(`${API_URL}/api/oauth/codes`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
);
|
||||
globalThis.location.assign(redirectTo);
|
||||
} catch (err) {
|
||||
setError(friendlyFetchError(err));
|
||||
setLeaving(false);
|
||||
}
|
||||
}, [authFetch, clientId, params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || home) return;
|
||||
let cancelled = false;
|
||||
authFetch(
|
||||
`${API_URL}/api/oauth/clients?client_id=${encodeURIComponent(clientId)}`,
|
||||
)
|
||||
.then((res) => expectOk<OAuthClient>(res))
|
||||
.then((found) => {
|
||||
if (cancelled) return;
|
||||
setClient(found);
|
||||
if (found.granted) approve();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(friendlyFetchError(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Asked once per instance; approving changes nothing to ask again.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, home, clientId]);
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to={loginPath(location.pathname + location.search)} />;
|
||||
}
|
||||
|
||||
if (home) {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="page-error-wrap">
|
||||
<ErrorCard
|
||||
title={t`Not from here`}
|
||||
message={t`Your account lives on ${home}: sign in to other instances from there.`}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return (
|
||||
<PageShell centered>
|
||||
{error
|
||||
? (
|
||||
<div className="page-error-wrap">
|
||||
<ErrorCard title={t`Can't sign in there`} message={error} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { name, host } = client;
|
||||
const username = user.username;
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title">
|
||||
<Trans>Sign in to {name}?</Trans>
|
||||
</h1>
|
||||
<p className="auth-consent">
|
||||
<Trans>
|
||||
<strong>{name}</strong>{" "}
|
||||
({host}) asks to sign you in with your account here,{" "}
|
||||
<strong>@{username}</strong>. It will receive your username, your
|
||||
email address and your avatar.
|
||||
</Trans>
|
||||
</p>
|
||||
{error && <ErrorCard title={t`Can't sign in there`} message={error} />}
|
||||
<div className="auth-consent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={approve}
|
||||
disabled={leaving}
|
||||
>
|
||||
{leaving
|
||||
? <Trans>Signing you in…</Trans>
|
||||
: <Trans>Continue as @{username}</Trans>}
|
||||
</button>
|
||||
<Link to="/" className="btn-secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
78
src/pages/FederationCallback.tsx
Normal file
78
src/pages/FederationCallback.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Renders the page another gerbeur instance sends a user back to once they
|
||||
* let it sign them in here, `/federation/callback`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import type { FederationSessionRequest } from "../../shared/federation.ts";
|
||||
import type { AuthResponse } from "../../shared/user.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
|
||||
|
||||
/**
|
||||
* Renders `/federation/callback?code=&state=`. It finishes the sign-in
|
||||
* (`POST /api/federation/sessions`), once: a code works only once, and a
|
||||
* second request would fail the sign-in. On success the user lands on `/`,
|
||||
* signed in.
|
||||
*/
|
||||
export function FederationCallback(): React.JSX.Element {
|
||||
useDocumentTitle(t`Log in`);
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
const [error, setError] = useState<string | null>(
|
||||
code && state ? null : t`This sign-in link is incomplete.`,
|
||||
);
|
||||
const sent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sent.current || !code || !state) return;
|
||||
sent.current = true;
|
||||
fetch(`${API_URL}/api/federation/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, state } satisfies FederationSessionRequest),
|
||||
})
|
||||
.then((res) => expectOk<Wire<AuthResponse>>(res))
|
||||
.then((data) => {
|
||||
login(deserializeAuthResponse(data));
|
||||
navigate("/", { replace: true });
|
||||
})
|
||||
.catch((err) => setError(friendlyFetchError(err)));
|
||||
}, [code, state, login, navigate]);
|
||||
|
||||
return (
|
||||
<PageShell centered>
|
||||
{error
|
||||
? (
|
||||
<div className="page-error-wrap">
|
||||
<ErrorCard title={t`Login failed`} message={error} />
|
||||
<p className="auth-card-footer">
|
||||
<Link to="/login">
|
||||
<Trans>Back to the login page</Trans>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p className="page-loading">
|
||||
<Trans>Signing you in…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Renders the site's tools, `/manage`, for those who hold `category:manage`.
|
||||
* Another user's role and name are managed on their profile's "manage" tab.
|
||||
* Renders the site's tools, `/manage`, for those who hold `category:manage` or
|
||||
* `federation:manage`. Another user's role and name are managed on their
|
||||
* profile's "manage" tab.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,24 +15,37 @@ import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
||||
import { PageGroup } from "../components/layout/PageGroup.tsx";
|
||||
import { CategoryManager } from "../components/category/CategoryManager.tsx";
|
||||
import { PeerManager } from "../components/federation/PeerManager.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
/**
|
||||
* Renders the category manager, signed in only; without `category:manage`,
|
||||
* which the API checks again, the page isn't found.
|
||||
* Renders the category manager and the instances the site trusts or blocks,
|
||||
* each to those who hold its permission, which the API checks again; without
|
||||
* either, the page isn't found.
|
||||
*/
|
||||
export function Manage(): React.JSX.Element {
|
||||
useDocumentTitle(t`Manage`);
|
||||
const { user } = useRequiredAuth();
|
||||
if (!can(user, "category:manage")) return <NotFound />;
|
||||
const categories = can(user, "category:manage");
|
||||
const instances = can(user, "federation:manage");
|
||||
if (!categories && !instances) return <NotFound />;
|
||||
return (
|
||||
<PageShell narrow>
|
||||
<PageHeader icon="🛠️" title={<Trans>Manage</Trans>} />
|
||||
<PageGroup label={<Trans>Categories</Trans>}>
|
||||
<div className="page-card">
|
||||
<CategoryManager />
|
||||
</div>
|
||||
</PageGroup>
|
||||
{categories && (
|
||||
<PageGroup label={<Trans>Categories</Trans>}>
|
||||
<div className="page-card">
|
||||
<CategoryManager />
|
||||
</div>
|
||||
</PageGroup>
|
||||
)}
|
||||
{instances && (
|
||||
<PageGroup label={<Trans>Instances</Trans>}>
|
||||
<div className="page-card">
|
||||
<PeerManager />
|
||||
</div>
|
||||
</PageGroup>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Navigate } from "react-router";
|
||||
import { Navigate, useSearchParams } from "react-router";
|
||||
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { nextPath } from "../utils/auth/next.ts";
|
||||
|
||||
/** Passed to {@link RestrictedGuest} by `App`, around a route's page. */
|
||||
export interface RestrictedGuestProps {
|
||||
@@ -15,12 +16,16 @@ export interface RestrictedGuestProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `children` when no one is signed in, and redirects to `/` otherwise.
|
||||
* Renders `children` when no one is signed in, and redirects otherwise: to the
|
||||
* page a `?next=` names (`nextPath`), or to `/`.
|
||||
*/
|
||||
export function RestrictedGuest(
|
||||
{ children }: RestrictedGuestProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
return user ? <Navigate to="/" /> : <>{children}</>;
|
||||
return user
|
||||
? <Navigate to={nextPath(params) ?? "/"} replace />
|
||||
: <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from "../components/shared/Segmented.tsx";
|
||||
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
|
||||
import { SettingRow } from "../components/profile/SettingRow.tsx";
|
||||
import { GrantList } from "../components/federation/GrantList.tsx";
|
||||
import { homeHost, homeUrl } from "../utils/auth/home.ts";
|
||||
|
||||
// The styles' names are their own, in every language.
|
||||
const STYLE_OPTIONS: SegmentedOption<StyleName>[] = [
|
||||
@@ -63,8 +65,9 @@ const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
|
||||
/**
|
||||
* Renders `/settings`, signed in only: the username, which only a moderator
|
||||
* changes (`ProfileManageTab`), the password change, which opens
|
||||
* {@link ChangePasswordModal}, and the settings, each saved as soon as it is
|
||||
* clicked. The style, color scheme and digests are saved to the account; the
|
||||
* {@link ChangePasswordModal}, the other instances the account signs in to
|
||||
* ({@link GrantList}), and the settings, each saved as soon as it is
|
||||
* clicked. An account from another instance changes its password there. The style, color scheme and digests are saved to the account; the
|
||||
* default feed tab and sounds, for now, to this browser.
|
||||
*/
|
||||
export function Settings(): React.JSX.Element {
|
||||
@@ -78,6 +81,7 @@ export function Settings(): React.JSX.Element {
|
||||
// Until the session has the settings, a click would save over unknown ones.
|
||||
const digestsDisabled = !EMAIL_ENABLED || !settings;
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
const home = homeHost(user.username);
|
||||
|
||||
return (
|
||||
<PageShell narrow>
|
||||
@@ -87,17 +91,46 @@ export function Settings(): React.JSX.Element {
|
||||
<SettingRow label={<Trans>Username</Trans>}>
|
||||
<span className="profile-appearance-value">{user.username}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label={<Trans>Password</Trans>}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => setChangePasswordOpen(true)}
|
||||
>
|
||||
<Trans>Change password…</Trans>
|
||||
</button>
|
||||
</SettingRow>
|
||||
{home
|
||||
? (
|
||||
<SettingRow label={<Trans>Password</Trans>}>
|
||||
<a
|
||||
className="btn btn--ghost"
|
||||
href={homeUrl(user.username, "/settings") ?? undefined}
|
||||
>
|
||||
<Trans>Change it on {home}</Trans>
|
||||
</a>
|
||||
</SettingRow>
|
||||
)
|
||||
: (
|
||||
<SettingRow label={<Trans>Password</Trans>}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => setChangePasswordOpen(true)}
|
||||
>
|
||||
<Trans>Change password…</Trans>
|
||||
</button>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
{home && (
|
||||
<p className="form-hint profile-appearance-note">
|
||||
<Trans>
|
||||
Your account lives on{" "}
|
||||
{home}: your password, email address, avatar and username change
|
||||
there.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</PageGroup>
|
||||
{!home && (
|
||||
<PageGroup label={<Trans>Other instances</Trans>}>
|
||||
<div className="page-card">
|
||||
<GrantList />
|
||||
</div>
|
||||
</PageGroup>
|
||||
)}
|
||||
<PageGroup label={<Trans>Appearance</Trans>}>
|
||||
<div className="page-card profile-appearance-grid">
|
||||
<SettingRow label={<Trans>Style</Trans>}>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
@@ -23,6 +23,8 @@ import { FormProvider } from "react-hook-form";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
|
||||
import { nextPath } from "../utils/auth/next.ts";
|
||||
|
||||
interface LoginValues {
|
||||
username: string;
|
||||
@@ -34,12 +36,14 @@ interface ResetValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/login`, for the signed out. A sign-in lands on `/`, and "Forgot
|
||||
* password?" unfolds the reset request form below.
|
||||
* Renders `/login`, for the signed out. A sign-in lands on the page `?next=`
|
||||
* names, or `/`, and "Forgot password?" unfolds the reset request form below.
|
||||
* Users of another instance sign in through {@link FederatedSignIn}.
|
||||
*/
|
||||
export function UserLogin(): React.JSX.Element {
|
||||
useDocumentTitle(t`Log in`);
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
|
||||
@@ -57,7 +61,7 @@ export function UserLogin(): React.JSX.Element {
|
||||
}),
|
||||
);
|
||||
login(deserializeAuthResponse(data));
|
||||
navigate("/");
|
||||
navigate(nextPath(params) ?? "/", { replace: true });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -100,6 +104,8 @@ export function UserLogin(): React.JSX.Element {
|
||||
|
||||
{showReset && <ResetRequestPanel />}
|
||||
|
||||
<FederatedSignIn />
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>This is a mirage.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FormProvider } from "react-hook-form";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
import { FederatedSignIn } from "../components/federation/FederatedSignIn.tsx";
|
||||
|
||||
type TokenState =
|
||||
| { status: "checking" }
|
||||
@@ -39,7 +40,8 @@ interface Values {
|
||||
|
||||
/**
|
||||
* Renders `/register?token=`, for the signed out. It checks the invite before
|
||||
* showing the form, and signs the new user in on success.
|
||||
* showing the form, and signs the new user in on success. A user of another
|
||||
* instance joins with that account instead, through {@link FederatedSignIn}.
|
||||
*/
|
||||
export function UserRegister(): React.JSX.Element {
|
||||
useDocumentTitle(t`Register`);
|
||||
@@ -171,6 +173,8 @@ export function UserRegister(): React.JSX.Element {
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<FederatedSignIn inviteToken={token} />
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>
|
||||
Already have an account? <Link to="/login">Log in</Link>
|
||||
|
||||
Reference in New Issue
Block a user