From 8f01e828ac36315a4034d1011c5c4770c6116c03 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 18:46:46 -0700 Subject: [PATCH] Rewrite sign-in, the callback and the API wrapper OAuth (discovery, PKCE with S256, the redirect, the code trade, the end-session URL), the client id, the login page, the callback and the API wrapper are rewritten from what they do, with new tests: RFC 7636's worked example for the challenge, the state check, refusal versus no answer on refresh, a shared refresh, the retry after a 401. The callback's checks move out of the page into services/auth/callback.ts, where they are tested directly. Storage keys, the sign-in parameters, strings and behavior are unchanged. Checked on a local server: a deep link through sign-in, a refresh, sign-out and a forged callback. --- src/lib/oauthClientId.test.ts | 78 ++---- src/lib/oauthClientId.ts | 43 ++-- src/pages/LoginPage.tsx | 56 ++-- src/pages/OAuthCallback.tsx | 133 +++------- src/services/api.test.ts | 78 ++++++ src/services/api.ts | 327 +++++++++++------------- src/services/auth/callback.test.ts | 121 +++++++++ src/services/auth/callback.ts | 63 +++++ src/services/auth/oauth.test.ts | 324 ++++++++++++++--------- src/services/auth/oauth.ts | 396 ++++++++++++++--------------- 10 files changed, 920 insertions(+), 699 deletions(-) create mode 100644 src/services/auth/callback.test.ts create mode 100644 src/services/auth/callback.ts diff --git a/src/lib/oauthClientId.test.ts b/src/lib/oauthClientId.test.ts index eebb1c2..892ce7f 100644 --- a/src/lib/oauthClientId.test.ts +++ b/src/lib/oauthClientId.test.ts @@ -1,73 +1,49 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -const originalHead = document.head.innerHTML; - -async function loadWithMeta(meta: string) { - document.head.innerHTML = meta; +/** The client id a fresh page with this would use. */ +async function clientIdFor(head: string) { + document.head.innerHTML = head; vi.resetModules(); - const { getOAuthClientId } = await import('./oauthClientId'); - return getOAuthClientId; + return (await import('./oauthClientId')).getOAuthClientId; } afterEach(() => { - document.head.innerHTML = originalHead; - vi.resetModules(); + document.head.innerHTML = ''; }); describe('getOAuthClientId', () => { - it('prefers the client id injected by the server', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('pocket-id-client'); + it('is inbuxa-webui when the server serves the console', async () => { + expect((await clientIdFor(''))()).toBe('inbuxa-webui'); + expect((await clientIdFor(''))()).toBe('inbuxa-webui'); }); - it('trims surrounding whitespace from the injected client id', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('pocket-id-client'); - }); - - it('falls back to the built-in default when the placeholder is empty', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('inbuxa-webui'); - }); - - it('falls back to the built-in default when the placeholder is only whitespace', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('inbuxa-webui'); - }); - - it('falls back to the built-in default when the placeholder is absent', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('inbuxa-webui'); - }); - - it('is inbuxa-admin when hosted apart from the server', async () => { - const getOAuthClientId = await loadWithMeta( - '', + it("is inbuxa-admin when the console is hosted apart, knowing the server's address", async () => { + expect((await clientIdFor(''))()).toBe( + 'inbuxa-admin', ); - expect(getOAuthClientId()).toBe('inbuxa-admin'); }); - it('still prefers an injected client id when hosted apart from the server', async () => { - const getOAuthClientId = await loadWithMeta( - '', - ); - expect(getOAuthClientId()).toBe('custom'); + it('takes a client id the page names, trimmed, either way', async () => { + expect((await clientIdFor(''))()).toBe('sso-console'); + expect( + ( + await clientIdFor( + '', + ) + )(), + ).toBe('sso-console'); }); - it('reads the document only once', async () => { - const getOAuthClientId = await loadWithMeta(''); - expect(getOAuthClientId()).toBe('pocket-id-client'); - - document.head.innerHTML = ''; - expect(getOAuthClientId()).toBe('pocket-id-client'); + it('reads the page once', async () => { + const get = await clientIdFor(''); + expect(get()).toBe('first'); + document.head.innerHTML = ''; + expect(get()).toBe('first'); }); }); diff --git a/src/lib/oauthClientId.ts b/src/lib/oauthClientId.ts index 679cc86..f3c94cb 100644 --- a/src/lib/oauthClientId.ts +++ b/src/lib/oauthClientId.ts @@ -1,33 +1,28 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -// INBUXA requires OAuth clients to be registered, and registers these two on -// every start (inbuxa-server contract C-6). Served by the server itself, this -// is the web interface at /admin, registered as `inbuxa-webui`. Hosted -// anywhere else, with the server's address in , it -// is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL. -const SERVED_BY_SERVER_CLIENT_ID = 'inbuxa-webui'; -const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin'; +/** + * The OAuth client the console signs in as. inbuxa only accepts registered + * clients and registers two on every start (inbuxa-server contract C-6): + * + * - `inbuxa-webui`: the console served by the server itself, at /admin; + * - `inbuxa-admin`: the console hosted anywhere else, which knows the + * server's address from VITE_API_BASE_URL or . + * + * A non-empty overrides both. Read once. + */ +let clientId: string | undefined; -let cached: string | undefined; - -function metaContent(name: string): string | undefined { - return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')?.trim() || undefined; -} +const meta = (name: string) => + document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')?.trim() || undefined; export function getOAuthClientId(): string { - if (cached !== undefined) return cached; - - const hostedElsewhere = Boolean( - (import.meta.env.VITE_API_BASE_URL as string | undefined) || metaContent('api-base-url'), - ); - cached = - metaContent('oauth-client-id') ?? (hostedElsewhere ? HOSTED_ELSEWHERE_CLIENT_ID : SERVED_BY_SERVER_CLIENT_ID); - return cached; + if (clientId === undefined) { + const elsewhere = !!(import.meta.env.VITE_API_BASE_URL || meta('api-base-url')); + clientId = meta('oauth-client-id') ?? (elsewhere ? 'inbuxa-admin' : 'inbuxa-webui'); + } + return clientId; } diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3e11da2..5ebae9d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,62 +1,57 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; import { ArrowRight, Loader2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; import Logo from '@/components/common/Logo'; -import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { SourceLink } from '@/components/common/SourceLink'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { startAuthFlow } from '@/services/auth/oauth'; import { clearReturnPath, readReturnPath } from '@/services/auth/session'; -import { SourceLink } from '@/components/common/SourceLink'; /** - * INBUXA: straight to the server's own sign-in page, which asks for the - * username and password. There is no account-name step first: INBUXA Admin - * talks to one known server, so there is nothing to look up per account (see - * `discover`). The card stays only for when getting there fails. + * Goes straight on to the server's own sign-in page, which asks for the + * username and password; the console talks to one server, so there is no + * account to look up first. The card shows only while that is on its way, + * or when getting there failed, with a button to try again. */ export default function LoginPage() { const { t } = useTranslation(); - const location = useLocation(); - // The page before signing in: from the route guard, or kept by a session - // the server ended (see `endSession`). - const originalPath = (location.state as { from?: string } | null)?.from ?? readReturnPath(); + const { state } = useLocation(); + // The page to come back to: from the route guard, or kept when the server ended the session (endSession). + const comeBackTo = (state as { from?: string } | null)?.from ?? readReturnPath(); const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(true); const started = useRef(false); - useDocumentTitle(t('login.title', 'Sign in')); - const go = useCallback(async () => { + const signIn = useCallback(async () => { setError(null); - setLoading(true); + setBusy(true); try { - await startAuthFlow(null, originalPath); + await startAuthFlow(null, comeBackTo); // The sign-in round trip carries it from here. clearReturnPath(); } catch (err) { setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred')); - setLoading(false); + setBusy(false); } - }, [originalPath, t]); + }, [comeBackTo, t]); useEffect(() => { - // Once, even under StrictMode's double effect. + // Once, though StrictMode runs effects twice. if (started.current) return; started.current = true; - void go(); - }, [go]); + void signIn(); + }, [signIn]); return (
@@ -64,15 +59,14 @@ export default function LoginPage() { - {error && ( -

+

{error}

)} -