Log out when the app was removed from the pod instead of re-asking consent

A registration read as installed:false means the user removed Festipod from their pod, often on purpose: re-running registerApp sent them to consent on every load with no way to sign out. The probe now logs out through the provider and lands on the picker with a notice that the authorization was removed and they can sign in again; Back and reload stay there. upgradeNeeded:true keeps the automatic consent redirect. The revocation e2e asserts the new path and fails on the previous one.
This commit is contained in:
Sylvain Duchesne
2026-10-12 16:27:02 +02:00
parent f32e166601
commit d4fcb7e311
4 changed files with 147 additions and 37 deletions
+18 -19
View File
@@ -43,7 +43,7 @@ import {
safeRedirectPath,
} from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
import { recoverRegistration, resolveStaleRegistration, type StaleReason } from './auth/appStatusProbe';
import { handleStaleReading, type StaleReason } from './auth/appStatusProbe';
import { useAppStatusProbe } from './auth/useAppStatusProbe';
/**
@@ -53,9 +53,9 @@ import { useAppStatusProbe } from './auth/useAppStatusProbe';
*/
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error';
/** Shown on the picker when a revoked/stale registration could not be re-run. */
/** Shown on the picker when the app was removed from the pod and the user was logged out. */
const REGISTRATION_LOST_NOTICE =
'Festipod n’est plus autorisé sur votre Pod. Reconnectez-vous pour l’autoriser à nouveau.';
'L’autorisation de Festipod a été retirée de votre Pod : vous avez été déconnecté. Vous pouvez vous reconnecter à tout moment.';
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
@@ -104,11 +104,11 @@ export function AuthGate({ children }: { children: ReactNode }) {
// Why the picker is back after a session existed (registration lost).
const [apNotice, setApNotice] = useState<string | null>(null);
// Set when the app-status probe took the connected view down: the consent
// redirect is under way ('reauthorizing'), or the registration is gone and
// could not be re-run ('registration-lost'). The provider's token is still
// stored (logout is not ours to trigger here), so without this any later
// route change — a Back, a link — would re-derive "authenticated" from that
// token and bring back a view that can neither read nor write. Lives for the
// redirect is under way ('reauthorizing'), or the app was removed from the
// pod and the user logged out ('registration-lost'). Should the logout itself
// fail the token stays stored, and without this any later route change — a
// Back, a link — would re-derive "authenticated" from it and bring back a
// view that can neither read nor write. Lives for the
// page: the consent and the picker's sign-in both leave it for the pod, and a
// reload probes afresh.
const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null);
@@ -243,28 +243,27 @@ export function AuthGate({ children }: { children: ReactNode }) {
return () => { cancelled = true; };
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
// A revoked or stale registration: re-run the provider's registration flow.
// Its consent redirect IS the recovery; a resolved URI means the registration
// is current after all and the app stays as it is. When it fails, what
// happens depends on what was found: a REVOKED registration (nothing can be
// read or written) lands on the picker with a notice; a registration that
// only needs an upgrade stays connected and is retried by the next probe.
// A revoked or stale registration. Revoked (the app was removed from the
// pod): log out through the provider and land on the picker with a notice —
// no consent redirect. Out of date (grants to update): re-run the provider's
// registration flow, whose consent redirect IS the recovery; if that cannot
// run, stay connected and let the next probe retry.
const handleStaleRegistration = useCallback(async (reason: StaleReason) => {
const provider = apAuthProvider;
if (!provider || recoveringRef.current) return;
recoveringRef.current = true;
let outcome: Awaited<ReturnType<typeof recoverRegistration>>;
let handled: Awaited<ReturnType<typeof handleStaleReading>>;
try {
outcome = await recoverRegistration({
handled = await handleStaleReading(reason, {
getWebId: () => provider.getSession()?.webId,
registerApp: (webId) => provider.registerApp(webId),
logout: async () => { await provider.logout({}); },
});
} finally {
recoveringRef.current = false;
}
const resolution = resolveStaleRegistration(reason, outcome);
console.info(`[AppStatus] recovery (${reason}) → ${outcome.kind} → ${resolution}`);
if (outcome.kind === 'failed') console.error('[AppStatus] registerApp failed:', outcome.detail);
const { resolution, detail } = handled;
console.info(`[AppStatus] handled (${reason}) → ${detail} → ${resolution}`);
switch (resolution) {
case 'stay-connected': {
// From the probe this changes nothing; from the reauthorizing screen's
+44
View File
@@ -3,6 +3,7 @@ import {
MIN_PROBE_INTERVAL_MS,
classifyAppStatus,
createAppStatusProbe,
handleStaleReading,
recoverRegistration,
resolveStaleRegistration,
type AppStatusProbeDeps,
@@ -317,3 +318,46 @@ describe('resolveStaleRegistration', () => {
expect(resolveStaleRegistration('upgrade-needed', failed)).toBe('stay-connected');
});
});
describe('handleStaleReading', () => {
function spies(opts: { registerApp?: () => Promise<string | undefined>; logout?: () => Promise<void> } = {}) {
const calls = { registerApp: 0, logout: 0 };
return {
calls,
deps: {
getWebId: () => 'http://pod/alice' as string | undefined,
registerApp: async () => { calls.registerApp++; return opts.registerApp ? opts.registerApp() : undefined; },
logout: async () => { calls.logout++; if (opts.logout) await opts.logout(); },
},
};
}
test('not-installed: logs out and never calls registerApp (no consent redirect)', async () => {
const { calls, deps } = spies();
const out = await handleStaleReading('not-installed', deps);
expect(out.resolution).toBe('registration-lost');
expect(calls).toEqual({ registerApp: 0, logout: 1 });
});
test('not-installed: a logout that throws still leaves the connected view, without registerApp', async () => {
const { calls, deps } = spies({ logout: async () => { throw new Error('storage unavailable'); } });
const out = await handleStaleReading('not-installed', deps);
expect(out.resolution).toBe('registration-lost');
expect(out.detail).toContain('storage unavailable');
expect(calls).toEqual({ registerApp: 0, logout: 1 });
});
test('upgrade-needed: re-runs registerApp (consent redirect) and does not log out', async () => {
const { calls, deps } = spies();
const out = await handleStaleReading('upgrade-needed', deps);
expect(out.resolution).toBe('reauthorizing');
expect(calls).toEqual({ registerApp: 1, logout: 0 });
});
test('upgrade-needed: a registerApp that throws fails open — stays connected, no logout', async () => {
const { calls, deps } = spies({ registerApp: async () => { throw new Error('boom'); } });
const out = await handleStaleReading('upgrade-needed', deps);
expect(out.resolution).toBe('stay-connected');
expect(calls).toEqual({ registerApp: 1, logout: 0 });
});
});
+40 -1
View File
@@ -26,6 +26,14 @@
* check, which acts on `upgradeNeeded` only, would miss it. The probe treats
* `installed === false` as stale too.
*
* WHAT STALE LEADS TO (user decision, 2026-10-12). The two readings are not the
* same thing. `upgrade-needed` (registered, grants out of date) re-runs
* `registerApp`: its consent redirect is the recovery. `not-installed` (the app
* was removed from the pod — often on purpose) does NOT go back to consent: the
* user is logged out through the provider's `logout` and lands on the picker
* with a notice, free to sign in again. Sending a deliberate revocation to the
* consent screen on every load left that user unable to log out.
*
* SCHEDULE. Event-driven, never a polling loop (the app has none, by
* doctrine): on app load once signed in, and when the window regains focus or
* becomes visible. Throttled — no two runs start less than a minute apart, and
@@ -209,7 +217,7 @@ export async function recoverRegistration(deps: RecoveryDeps): Promise<RecoveryO
}
}
/** What the app does with a stale reading once the recovery has run. */
/** What the app does with a stale reading. */
export type StaleResolution =
/** Nothing to do: the registration is current after all, or a failed upgrade recovery fails open. */
| 'stay-connected'
@@ -236,3 +244,34 @@ export function resolveStaleRegistration(reason: StaleReason, outcome: RecoveryO
case 'failed': return reason === 'upgrade-needed' ? 'stay-connected' : 'registration-lost';
}
}
export interface StaleReadingDeps extends RecoveryDeps {
/** The provider's `logout` (clears the stored token). */
logout: () => Promise<void>;
}
/**
* Act on one stale reading. A revoked registration (`not-installed`) is never
* sent back to consent: it logs out (a logout that throws is logged by the
* caller through the detail; the app still leaves the connected view) and
* resolves `registration-lost`. An out-of-date one (`upgrade-needed`) re-runs
* `registerApp` and resolves through `resolveStaleRegistration`.
*/
export async function handleStaleReading(
reason: StaleReason,
deps: StaleReadingDeps,
): Promise<{ resolution: StaleResolution; detail: string }> {
if (reason === 'not-installed') {
try {
await deps.logout();
return { resolution: 'registration-lost', detail: 'logged out' };
} catch (err) {
return { resolution: 'registration-lost', detail: `logout failed: ${describeFailure(err)}` };
}
}
const outcome = await recoverRegistration(deps);
return {
resolution: resolveStaleRegistration(reason, outcome),
detail: outcome.kind === 'failed' ? `registerApp failed: ${outcome.detail}` : outcome.kind,
};
}