ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback

This commit is contained in:
Sylvain Duchesne
2026-10-09 14:07:47 +02:00
parent e17398d86d
commit b09884d586
10 changed files with 510 additions and 68 deletions
+19 -12
View File
@@ -4,6 +4,7 @@ import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
import { initialBackendName } from '../shared/refine/providers';
// Auth
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
@@ -37,6 +38,7 @@ function AppContent() {
case 'home': return <HomeScreen />;
case 'events': return <EventsScreen />;
case 'create-event': return <CreateEventScreen />;
case 'login': return null;
case 'event-detail': return <EventDetailScreen />;
case 'update-event': return <UpdateEventScreen />;
case 'invite': return <InviteScreen />;
@@ -53,20 +55,25 @@ function AppContent() {
}
export function App() {
const backendName = initialBackendName();
const isNg = backendName === 'nextgraph';
const appTree = (
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
);
return (
<ThemeProvider>
<NextGraphProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</NextGraphProvider>
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
</ThemeProvider>
);
}
+151 -46
View File
@@ -1,83 +1,147 @@
/**
* AuthGate — the ONE await the application makes before it renders.
*
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
* resolves who we are and does the connection work (restoring what others shared
* with us, draining our inboxes). Whatever a user has to see or do while that
* resolves — opening the wallet, loading it onto a first-time device — belongs to
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
* takes it down itself, and it owns the return from the broker round-trip (the
* barrier comes back prefilled, and confirming it hands the page over a second
* time; our page is never reloaded and nothing outside the barrier is touched).
* Festipod renders no access screen of its own and re-drives nothing.
* Signing in is backend-specific. There are three paths in this gate:
*
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
* then await `ensureIdentity()`. A session arrives only through `init`, and
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
* of the statement sequence below, not of React's effect ordering — which would
* get it wrong: this gate's effect runs BEFORE its parent provider's.
* 1. ACTIVITYPODS (default variant). The app renders the Solid-OIDC flow itself:
* a Pod-provider picker when no session exists, and the `/login` route handling
* the OAuth callback plus the ActivityPods app-registration stage. The auth
* provider is built with the app description document served by our backend as
* `clientId` and `${origin}/login` as `redirectUri`.
*
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
* question — everything else it knows about the user it has to read first. The
* value is published for display (`shared/utils/currentPrincipal`) and goes
* nowhere near a data call: no call takes an identity, because the session
* already belongs to one user.
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
* the broker round-trip.
*
* Nothing of the app renders before that await settles: a screen mounted earlier
* would read as an identity that is not yet settled.
* 3. LOCAL DEMO. When no ActivityPods backend is configured, or when the user
* explicitly chooses to continue offline, children render without an
* authenticated session and `FestipodDataProvider` serves the seeded demo data.
*
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
* app degrades through: an app that could not sign in but still shows its screens
* is indistinguishable from an app whose user simply owns nothing — a total
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
* children stay unmounted, which is also what keeps the data layer from settling
* on its empty stand-in provider for the rest of the session.
* Whatever a user has to see or do while signing in belongs on screen: the
* ActivityPods picker replaces the old wallet barrier, and the local demo fallback
* remains reachable.
*/
import { useEffect, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
import { useRouter, useNavigate } from './router';
import { initialBackendName } from '../shared/refine/providers';
import { getBackendUrl } from '../shared/utils/runtimeConfig';
import { buildActivityPodsAuthProvider, parseAuthFlowStage } from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
export function AuthGate({ children }: { children: ReactNode }) {
const { route } = useRouter();
const navigate = useNavigate();
// Whether the ONE identity await has resolved.
const backendName = initialBackendName();
const isActivityPods = backendName === 'activitypods';
const isNg = backendName === 'nextgraph';
const activityPodsEnabled = isActivityPods && !!getBackendUrl();
const [demoMode, setDemoMode] = useState(false);
// Whether the ONE identity await has resolved (NextGraph path).
const [identityReady, setIdentityReady] = useState(false);
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
const [signInError, setSignInError] = useState<string | null>(null);
// ActivityPods flow state.
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
const [apError, setApError] = useState<string | null>(null);
const apAuthProvider = useMemo(() => {
if (!activityPodsEnabled) return undefined;
return buildActivityPodsAuthProvider(window.location.origin);
}, [activityPodsEnabled]);
// NextGraph identity flow — unchanged.
useEffect(() => {
if (!isNg) return;
let cancelled = false;
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
// provider above may have started it already; what matters is that it has been
// called before the await below, or the await throws.
void startNgSession();
void ensureIdentity()
.then(principal => {
// Publish who we are BEFORE anything renders — the identity is a fact of
// the session, not state of this component, so it is recorded even if the
// effect was torn down in between.
.then((principal) => {
setCurrentPrincipal(principal);
if (!cancelled) setIdentityReady(true);
})
.catch(err => {
.catch((err) => {
console.error('[Auth] ensureIdentity failed:', err);
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, []);
}, [isNg]);
// ActivityPods Solid-OIDC + app-registration flow.
useEffect(() => {
if (!activityPodsEnabled || !apAuthProvider || demoMode) return;
const provider = apAuthProvider;
let cancelled = false;
async function run() {
const stage = parseAuthFlowStage(window.location.search);
try {
if (stage.stage === 'callback') {
const result = await provider.handleCallback();
if (cancelled) return;
setCurrentPrincipal(result.webId);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`);
return;
}
if (stage.stage === 'register_app') {
const session = provider.getSession();
const registration = session ? await provider.registerApp(session.webId) : undefined;
if (cancelled) return;
if (registration) {
if (session) setCurrentPrincipal(session.webId);
navigate(stage.redirectPath);
}
// If `registerApp` returned `undefined`, the browser left for the Pod's
// consent screen. The effect is torn down; it will resume when the user
// comes back.
return;
}
const session = provider.getSession();
if (session) {
setCurrentPrincipal(session.webId);
if (!cancelled) setApState('authenticated');
} else {
if (!cancelled) setApState('unauthenticated');
}
} catch (err) {
console.error('[Auth] ActivityPods flow failed:', err);
if (!cancelled) {
setApError(err instanceof Error ? err.message : String(err));
setApState('error');
}
}
}
void run();
return () => { cancelled = true; };
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
// Once identified, leave the welcome screen for the app home.
useEffect(() => {
if (apState === 'authenticated' && route.page === 'welcome') {
navigate('/home');
}
}, [apState, route.page, navigate]);
// Once identified, leave the disconnected welcome screen for the app home.
useEffect(() => {
if (identityReady && route.page === 'welcome') {
navigate('/home');
}
}, [identityReady, route.page, navigate]);
// Signing in FAILED — say so. The app has nothing legitimate to show, and
// showing it anyway would pass a broken session off as an empty one.
// Signing in FAILED — say so. The app has nothing legitimate to show.
if (signInError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
@@ -91,13 +155,54 @@ export function AuthGate({ children }: { children: ReactNode }) {
);
}
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
// barrier is what is on screen, it put it there and it takes it down. Anything
// of ours here would be a second thing competing with it.
if (!identityReady) {
if (apError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
<h1 className="app-title">Connexion impossible</h1>
<p className="app-text">
Festipod n’a pas réussi à se connecter à votre Pod. Vérifiez l’URL du
provider et réessayez.
</p>
<p className="app-text" data-testid="auth-error-detail">{apError}</p>
<button
type="button"
className="app-btn"
onClick={() => {
setApError(null);
setApState('unauthenticated');
}}
style={{ marginTop: 16 }}
>
Retour
</button>
</div>
);
}
// ActivityPods variant: show the picker until the user has a session.
if (isActivityPods && activityPodsEnabled && !demoMode) {
if (apState === 'checking') {
return null;
}
if (apState === 'unauthenticated') {
return (
<PodProviderPicker
authProvider={apAuthProvider!}
onDemo={() => {
setDemoMode(true);
navigate('/home');
}}
/>
);
}
// authenticated → fall through to children
}
// NextGraph variant: the SDK's own full-screen barrier is what is on screen.
if (isNg && !identityReady) {
return null;
}
// The app.
// The app (local demo or authenticated ActivityPods / NextGraph).
return <>{children}</>;
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react';
import type { AuthProvider } from '@activitypods/refine-providers';
import { Button, Input, Title, Text } from '../../shared/components/sketchy';
import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
interface PodProviderPickerProps {
authProvider: AuthProvider;
onDemo?: () => void;
}
/**
* Pod-provider picker — the ActivityPods sign-in screen.
*
* When a default Pod provider is configured via `VITE_POD_PROVIDER_URL`, it is
* offered as a one-click option. The user can also type any Solid Pod provider
* URL. A "continue offline" button keeps the local demo fallback reachable.
*/
export function PodProviderPicker({ authProvider, onDemo }: PodProviderPickerProps) {
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
const [customUrl, setCustomUrl] = useState('');
const startLogin = (issuer: string) => {
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect') || '/home';
void authProvider.login({ issuer, redirect });
};
const defaultProviderHost = podProviderUrl ? safeHost(podProviderUrl) : undefined;
return (
<div
style={{
padding: 24,
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '100dvh',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 16 }}>
Festipod
</Title>
<Text
style={{
textAlign: 'center',
fontSize: 16,
marginBottom: 32,
lineHeight: 1.5,
color: '#555',
}}
>
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
</Text>
{defaultProviderHost && (
<Button
variant="primary"
onClick={() => startLogin(podProviderUrl!)}
style={{ marginBottom: 16 }}
>
Se connecter avec {defaultProviderHost}
</Button>
)}
<div style={{ marginBottom: 24 }}>
<Input
type="url"
placeholder="URL de votre Pod provider"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
style={{ marginBottom: 8, width: '100%' }}
/>
<Button
variant="default"
onClick={() => customUrl && startLogin(customUrl)}
disabled={!customUrl}
style={{ width: '100%' }}
>
Continuer
</Button>
</div>
</div>
{onDemo && (
<Button variant="accent-outline" onClick={onDemo} style={{ marginBottom: 12 }}>
Continuer hors ligne
</Button>
)}
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta
</Text>
</div>
);
}
function safeHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from 'bun:test';
import { setRuntimeConfig } from '../../shared/utils/runtimeConfig';
import {
parseAuthFlowStage,
resolveActivityPodsAuthConfig,
type AuthFlowStage,
} from './activityPodsAuth';
test('clientId uses explicit backendClientId when set', () => {
setRuntimeConfig({ backendClientId: 'https://app.example/id.json' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:3000');
expect(cfg.clientId).toBe('https://app.example/id.json');
expect(cfg.redirectUri).toBe('http://localhost:3000/login');
});
test('clientId derives from backendUrl when backendClientId is empty', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/app');
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
});
test('clientId falls back to origin/app when nothing is configured', () => {
setRuntimeConfig({});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/app');
});
test('podProviderUrl is passed through when set', () => {
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.podProviderUrl).toBe('http://localhost:3000');
});
test('whitespace-only runtime values are treated as empty', () => {
setRuntimeConfig({
backendUrl: ' ',
backendClientId: ' ',
podProviderUrl: ' ',
});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/app');
expect(cfg.podProviderUrl).toBeUndefined();
});
test('parseAuthFlowStage detects OIDC callback', () => {
expect(parseAuthFlowStage('?code=abc&state=xyz')).toEqual({
stage: 'callback',
redirectPath: '/home',
});
expect(parseAuthFlowStage('?code=abc&redirect=/events')).toEqual({
stage: 'callback',
redirectPath: '/events',
});
});
test('parseAuthFlowStage detects app registration stage', () => {
expect(parseAuthFlowStage('?register_app=1')).toEqual({
stage: 'register_app',
redirectPath: '/home',
});
expect(parseAuthFlowStage('?register_app=1&redirect=/profile')).toEqual({
stage: 'register_app',
redirectPath: '/profile',
});
});
test('parseAuthFlowStage defaults to picker', () => {
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker' });
// A bare `?redirect` does not change the stage; the redirect path is only read
// when the user lands back from the Pod provider.
expect(parseAuthFlowStage('?redirect=/events')).toEqual({ stage: 'picker' });
});
+71
View File
@@ -0,0 +1,71 @@
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
import type { AuthProvider } from '@activitypods/refine-providers';
import { getRuntimeConfig } from '../../shared/utils/runtimeConfig';
/**
* Resolved ActivityPods auth configuration.
*
* `clientId` names the app description document the backend serves, as required
* by the Solid-OIDC flow. `redirectUri` is fixed to `${origin}/login` because the
* app renders its own callback route there.
*/
export interface ResolvedAuthConfig {
clientId: string;
redirectUri: string;
podProviderUrl: string | undefined;
}
/**
* Resolve the auth configuration from runtime values.
*
* - `backendClientId` wins when explicitly set.
* - Otherwise, `backendUrl` is used to derive `${backendUrl}/app`.
* - Otherwise, fall back to `${origin}/app` so local dev still typechecks and
* can be exercised without a backend.
*/
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
const cfg = getRuntimeConfig();
const backendClientId = cfg.backendClientId?.trim();
const backendUrl = cfg.backendUrl?.trim();
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
return {
clientId,
redirectUri: `${origin}/login`,
podProviderUrl: cfg.podProviderUrl?.trim() || undefined,
};
}
/** Build an ActivityPods auth provider with the Festipod-specific configuration. */
export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
const cfg = resolveActivityPodsAuthConfig(origin);
return createActivityPodsAuthProvider({
clientId: cfg.clientId,
redirectUri: cfg.redirectUri,
appStatusCheckInterval: false,
});
}
/** Which stage of the Solid-OIDC / app-registration flow the current URL represents. */
export type AuthFlowStage =
| { stage: 'callback'; redirectPath: string }
| { stage: 'register_app'; redirectPath: string }
| { stage: 'picker' };
/**
* Parse the current URL search params to decide what AuthGate should do.
*
* - `?code=...` → the Pod provider redirected back after login; complete the
* OAuth exchange and move to app registration.
* - `?register_app=1` → back from the Pod's consent screen; ensure the app is
* registered before entering the app.
* - otherwise → show the Pod-provider picker.
*
* `redirect` is preserved through the stages and defaults to `/home`.
*/
export function parseAuthFlowStage(search: string): AuthFlowStage {
const params = new URLSearchParams(search);
const redirectPath = params.get('redirect') || '/home';
if (params.has('code')) return { stage: 'callback', redirectPath };
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
return { stage: 'picker' };
}
+27 -5
View File
@@ -24,16 +24,38 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { setRuntimeConfig } from "../shared/utils/runtimeConfig";
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
/** Runtime config shape served by `/festipod-config.json`. */
interface FestipodConfig {
sharedWalletPassword?: string;
autoSeed?: string;
backendUrl?: string;
backendClientId?: string;
podProviderUrl?: string;
}
/**
* Fetch the runtime config and make it available to the app.
*
* The shared-wallet globals may already be inlined by `build.ts`'s `define`; the
* runtime endpoint is still fetched for the ActivityPods backend values, which
* are env-driven and not baked into the bundle.
*/
async function loadRuntimeConfig(): Promise<void> {
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
// so that same `define` (which rewrites the dotted global) leaves this read alone.
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
try {
const res = await fetch("/festipod-config.json");
if (!res.ok) return;
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
const cfg = (await res.json()) as FestipodConfig;
// Backend coupling: the browser cannot read process.env, so the server exposes
// the values the ActivityPods auth flow needs.
setRuntimeConfig({
backendUrl: cfg.backendUrl,
backendClientId: cfg.backendClientId,
podProviderUrl: cfg.podProviderUrl,
});
// Bracket access so build.ts's `define` (which matches the dotted global)
// never rewrites this assignment. Only set when the env actually carries one.
const g = globalThis as Record<string, unknown>;
+4 -1
View File
@@ -20,7 +20,8 @@ type Route =
| { page: 'share-profile' }
| { page: 'connect' }
| { page: 'user-profile'; userId: string }
| { page: 'settings' };
| { page: 'settings' }
| { page: 'login' };
export type { Route };
@@ -40,6 +41,7 @@ function parsePath(pathname: string): Route {
if (path === '/home') return { page: 'home' };
if (path === '/events') return { page: 'events' };
if (path === '/events/new') return { page: 'create-event' };
if (path === '/login') return { page: 'login' };
if (path === '/settings') return { page: 'settings' };
if (path === '/profile') return { page: 'profile' };
if (path === '/profile/edit') return { page: 'edit-profile' };
@@ -74,6 +76,7 @@ export function routeToPath(route: Route): string {
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'login': return '/login';
case 'event-detail': return `/events/${route.eventId}`;
case 'update-event': return `/events/${route.eventId}/edit`;
case 'invite': return `/events/${route.eventId}/invite`;
+12 -3
View File
@@ -63,13 +63,22 @@ const server = serve({
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
// entry (frontend.tsx) fetches this before it loads the app tree, so
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
"/festipod-config.json": () =>
Response.json({
"/festipod-config.json": () => {
const backendUrl = process.env.VITE_BACKEND_URL?.trim() ?? "";
const backendClientId = process.env.VITE_BACKEND_CLIENT_ID?.trim() ?? "";
return Response.json({
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
// Auto-seed gate (OFF by default): only set when the env var is present, so
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
}),
// ActivityPods backend coupling: the frontend needs the backend URL and the
// Solid-OIDC client_id (defaults to the app description document served by
// the backend). The Pod provider URL is optional; empty → user picks one.
backendUrl,
backendClientId,
podProviderUrl: process.env.VITE_POD_PROVIDER_URL?.trim() ?? "",
});
},
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
//
+38
View File
@@ -0,0 +1,38 @@
/**
* Runtime configuration — values the server injects via `/festipod-config.json`.
*
* The browser bundle cannot read `process.env`, and `build.ts` only bakes in the
* shared-wallet material. Everything else the frontend needs at runtime lives
* here: backend URL, Solid-OIDC client id, and default Pod provider URL.
*
* `frontend.tsx` fetches the config endpoint and calls `setRuntimeConfig` before
* the app tree evaluates, so any module that imports this file reads the current
* values. Keeping the shape flat and env-driven lets dev/staging/prod differ
* without rebuilding.
*/
export interface RuntimeConfig {
/** URL of the Festipod backend as seen by the browser. */
backendUrl?: string;
/** Solid-OIDC `client_id`; when empty, derived as `${backendUrl}/app`. */
backendClientId?: string;
/** Default Pod provider URL; when empty, the picker asks the user. */
podProviderUrl?: string;
}
let runtimeConfig: RuntimeConfig = {};
/** Replace the runtime config. Called once by `frontend.tsx` before `App` loads. */
export function setRuntimeConfig(cfg: RuntimeConfig): void {
runtimeConfig = cfg;
}
/** The current runtime config. */
export function getRuntimeConfig(): RuntimeConfig {
return runtimeConfig;
}
/** URL of the Festipod backend, when configured. */
export function getBackendUrl(): string | undefined {
return runtimeConfig.backendUrl?.trim() || undefined;
}