Rewrite sign-in, the callback and the API wrapper #124

Merged
jcoffey-dev merged 1 commits from phase3/auth into main 2026-10-19 02:10:50 +00:00
10 changed files with 920 additions and 699 deletions

No files matched your search

+27 -51
View File
@@ -1,73 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 <head> 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('<meta name="oauth-client-id" content="pocket-id-client" />');
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('<meta name="oauth-client-id" content=" " />'))()).toBe('inbuxa-webui');
});
it('trims surrounding whitespace from the injected client id', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" pocket-id-client " />');
expect(getOAuthClientId()).toBe('pocket-id-client');
});
it('falls back to the built-in default when the placeholder is empty', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
expect(getOAuthClientId()).toBe('inbuxa-webui');
});
it('falls back to the built-in default when the placeholder is only whitespace', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
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(
'<meta name="oauth-client-id" content="" /><meta name="api-base-url" content="https://mail.example.org" />',
it("is inbuxa-admin when the console is hosted apart, knowing the server's address", async () => {
expect((await clientIdFor('<meta name="api-base-url" content="https://mail.example.org" />'))()).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(
'<meta name="oauth-client-id" content="custom" /><meta name="api-base-url" content="https://mail.example.org" />',
);
expect(getOAuthClientId()).toBe('custom');
it('takes a client id the page names, trimmed, either way', async () => {
expect((await clientIdFor('<meta name="oauth-client-id" content=" sso-console " />'))()).toBe('sso-console');
expect(
(
await clientIdFor(
'<meta name="oauth-client-id" content="sso-console" /><meta name="api-base-url" content="https://mail.example.org" />',
)
)(),
).toBe('sso-console');
});
it('reads the document only once', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="pocket-id-client" />');
expect(getOAuthClientId()).toBe('pocket-id-client');
document.head.innerHTML = '<meta name="oauth-client-id" content="changed-later" />';
expect(getOAuthClientId()).toBe('pocket-id-client');
it('reads the page once', async () => {
const get = await clientIdFor('<meta name="oauth-client-id" content="first" />');
expect(get()).toBe('first');
document.head.innerHTML = '<meta name="oauth-client-id" content="second" />';
expect(get()).toBe('first');
});
});
+19 -24
View File
@@ -1,33 +1,28 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 <meta name="api-base-url">, 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 <meta name="api-base-url">.
*
* A non-empty <meta name="oauth-client-id"> 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;
}
+25 -31
View File
@@ -1,62 +1,57 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-content-background px-4">
@@ -64,15 +59,14 @@ export default function LoginPage() {
<CardHeader className="items-center text-center">
<Logo />
</CardHeader>
<CardContent className="space-y-4">
{error && (
<p className="text-sm text-destructive" role="alert">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<Button type="button" className="w-full" disabled={loading} onClick={() => void go()}>
{loading ? (
<Button type="button" className="w-full" disabled={busy} onClick={() => void signIn()}>
{busy ? (
<Loader2 className="animate-spin" />
) : (
<>
+37 -96
View File
@@ -1,126 +1,67 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { getBasePath } from '@/lib/basePath';
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
import { finishSignIn } from '@/services/auth/callback';
import { claimCode, settleCode } from '@/services/auth/codeOnce';
import { useAuthStore } from '@/stores/authStore';
/** Where the server sends the browser after sign-in: trades the code, then goes on to the console. */
export default function OAuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [params] = useSearchParams();
const [error, setError] = useState<string | null>(null);
useDocumentTitle(t('oauth.title', 'Signing in'));
useEffect(() => {
async function handleCallback() {
// inbuxa: each code is traded once (see codeOnce.ts). Back on this page
// with a code already traded, go on as signed in, or sign in afresh.
const code = searchParams.get('code');
if (code) {
const claim = claimCode(code);
if (claim === 'inFlight') return;
if (claim === 'traded') {
navigate(useAuthStore.getState().isAuthenticated() ? '/' : '/login', { replace: true });
return;
}
}
let traded = false;
try {
const stateParam = searchParams.get('state');
const errorParam = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (errorParam) {
throw new Error(errorDescription || errorParam);
}
if (!code || !stateParam) {
throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter'));
}
const { codeVerifier, tokenEndpoint, state, returnUrl, endSessionEndpoint } = getStoredOAuthData();
if (!state || state !== stateParam) {
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
}
if (!codeVerifier || !tokenEndpoint) {
throw new Error(t('oauth.missingData', 'Missing OAuth session data. Please try logging in again.'));
}
const redirectUri = getOAuthRedirectUri();
const tokenResponse = await exchangeCode(code, codeVerifier, tokenEndpoint, redirectUri);
useAuthStore
.getState()
.setTokens(
tokenResponse.access_token,
tokenResponse.refresh_token,
tokenResponse.expires_in,
tokenEndpoint,
endSessionEndpoint,
);
traded = true;
clearStoredOAuthData();
const basePath = getBasePath();
const stripBase = (p: string) => (basePath && p.startsWith(basePath) ? p.slice(basePath.length) || '/' : p);
const candidate = returnUrl ? stripBase(returnUrl) : '/';
const isAuthPath =
candidate === '/login' ||
candidate.startsWith('/login?') ||
candidate === '/oauth/callback' ||
candidate.startsWith('/oauth/callback?');
const destination = isAuthPath ? '/' : candidate;
navigate(destination, { replace: true });
} catch (err) {
clearStoredOAuthData();
setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed'));
} finally {
if (code) settleCode(code, traded);
}
// Each code is traded once (codeOnce.ts): the effect can run twice, and a
// reload brings a spent code back.
const code = params.get('code');
const claim = code ? claimCode(code) : 'trade';
if (claim === 'inFlight') return;
if (claim === 'traded') {
navigate(useAuthStore.getState().isAuthenticated() ? '/' : '/login', { replace: true });
return;
}
let traded = false;
finishSignIn(params)
.then((to) => {
traded = true;
navigate(to, { replace: true });
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed')))
.finally(() => {
if (code) settleCode(code, traded);
});
}, [navigate, params, t]);
handleCallback();
}, [navigate, searchParams, t]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{error ? (
<div className="w-full max-w-sm space-y-4 text-center">
<p className="text-sm text-destructive" role="alert">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
<a href={`${getBasePath()}/login`} className="text-sm text-primary underline-offset-4 hover:underline">
{t('oauth.backToLogin', 'Back to login')}
</a>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('oauth.processing', 'Completing sign in...')}</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('oauth.processing', 'Completing sign in...')}</p>
</div>
)}
</div>
);
}
+78
View File
@@ -135,3 +135,81 @@ describe('apiFetch', () => {
expect(endSession).not.toHaveBeenCalled();
});
});
describe('refreshAccessToken, answered', () => {
it.each([400, 401, 403])('ends the session when the server refuses with a %i', async (status) => {
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
fetchMock.mockResolvedValueOnce(json({ error: 'invalid_grant' }, status));
await expect(refreshAccessToken()).rejects.toThrow(`Token refresh failed: ${status}`);
expect(endSession).toHaveBeenCalledTimes(1);
expect(useConnectionStore.getState().unreachable).toBe(false);
});
it('keeps the new tokens, and the old refresh token when none comes back', async () => {
const { refreshAccessToken, useAuthStore } = await load();
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
fetchMock.mockResolvedValueOnce(json({ access_token: 'acc2', refresh_token: 'ref2', expires_in: 600 }));
await refreshAccessToken();
expect(useAuthStore.getState()).toMatchObject({ accessToken: 'acc2', refreshToken: 'ref2' });
fetchMock.mockResolvedValueOnce(json({ access_token: 'acc3', expires_in: 600 }));
await refreshAccessToken();
expect(useAuthStore.getState()).toMatchObject({ accessToken: 'acc3', refreshToken: 'ref2' });
const body = new URLSearchParams(String(fetchMock.mock.calls[1][1]?.body));
expect(Object.fromEntries(body)).toMatchObject({ grant_type: 'refresh_token', refresh_token: 'ref2' });
});
it('shares one refresh between callers that ask at once', async () => {
const { refreshAccessToken, useAuthStore } = await load();
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
fetchMock.mockResolvedValueOnce(json({ access_token: 'acc2', expires_in: 600 }));
await Promise.all([refreshAccessToken(), refreshAccessToken(), refreshAccessToken()]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('apiFetch, answered', () => {
it('refreshes once on a 401 and sends the request again with the new token', async () => {
const { apiFetch, useAuthStore } = await load();
useAuthStore.getState().setTokens('old', 'ref', 3600, 'https://mail.test/token');
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(json({ access_token: 'new', expires_in: 600 }))
.mockResolvedValueOnce(json({ ok: true }));
expect((await apiFetch('/jmap/session')).status).toBe(200);
expect(fetchMock.mock.calls[2][0]).toBe(`${window.location.origin}/jmap/session`);
expect(new Headers(fetchMock.mock.calls[2][1]?.headers).get('Authorization')).toBe('Bearer new');
});
it('ends the session on a 401 with nothing to refresh with', async () => {
const { apiFetch, ApiError } = await load();
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }));
await expect(apiFetch('/jmap/session')).rejects.toBeInstanceOf(ApiError);
expect(endSession).toHaveBeenCalledTimes(1);
});
it("throws an ApiError carrying the server's JSON, or null", async () => {
const { apiFetch, useAuthStore } = await load();
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
fetchMock.mockResolvedValueOnce(json({ detail: 'nope' }, 422));
await expect(apiFetch('/x')).rejects.toMatchObject({ status: 422, body: { detail: 'nope' } });
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 502, statusText: 'Bad Gateway' }));
await expect(apiFetch('/x')).rejects.toMatchObject({ status: 502, statusText: 'Bad Gateway', body: null });
});
});
describe('getApiBaseUrl', () => {
afterEach(() => {
document.head.innerHTML = '';
});
it("is the page's api-base-url without a trailing slash, else the console's origin", async () => {
const { getApiBaseUrl } = await load();
expect(getApiBaseUrl()).toBe(window.location.origin);
document.head.innerHTML = '<meta name="api-base-url" content=" https://mail.example.org// " />';
expect(getApiBaseUrl()).toBe('https://mail.example.org');
});
});
+151 -176
View File
@@ -1,35 +1,43 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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
*/
/**
* Requests to the server, signed with the admin's access token, which is
* refreshed before it runs out and again if the server says 401.
*
* Only a refusal from the token endpoint ends the session. No answer, a 5xx,
* rate limiting or a proxy's error page leave the admin signed in on the
* page, with a note that the server can't be reached (connectionStore), and
* the refresh is tried again on its own.
*/
import { getOAuthClientId } from '@/lib/oauthClientId';
import { useAuthStore } from '../stores/authStore';
import { useConnectionStore } from '../stores/connectionStore';
import { endSession } from './auth/session';
import { getOAuthClientId } from '@/lib/oauthClientId';
const withoutTrailingSlash = (url: string) => url.replace(/\/+$/, '');
/**
* The server's address: VITE_API_BASE_URL in development, else
* <meta name="api-base-url"> (set when the console is deployed, so one build
* fits any server), else the console's own origin (served by the server).
*/
export function getApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
if (envUrl && envUrl.length > 0) {
return envUrl.replace(/\/+$/, '');
}
// Set at deploy time rather than build time, so one build can point at any
// server: <meta name="api-base-url" content="https://mail.example.com">.
const injected = document.querySelector('meta[name="api-base-url"]')?.getAttribute('content')?.trim();
if (injected) {
return injected.replace(/\/+$/, '');
}
return window.location.origin;
const configured =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ||
document.querySelector('meta[name="api-base-url"]')?.getAttribute('content')?.trim();
return configured ? withoutTrailingSlash(configured) : window.location.origin;
}
/** A request the server answered with an error status; `body` is its JSON, if any. */
export class ApiError extends Error {
status: number;
statusText: string;
body: unknown;
readonly status: number;
readonly statusText: string;
readonly body: unknown;
constructor(status: number, statusText: string, body: unknown) {
super(`API error ${status}: ${statusText}`);
@@ -40,191 +48,158 @@ export class ApiError extends Error {
}
}
let refreshPromise: Promise<void> | null = null;
// --- Retrying a refresh the server didn't answer ---------------------------
/**
* inbuxa: the token endpoint turned the refresh token down (spent, revoked,
* or the account locked), so the session is over. Anything else, no answer
* at all, a 5xx or rate limiting, passes, and signing out over it would lose
* the page and whatever was typed into it.
*/
function isRefusal(status: number): boolean {
return status === 400 || status === 401 || status === 403;
}
/** After 5, 10 and 20 seconds, then every minute; and at once when the browser is back online. */
const RETRY_AFTER_MS = [5_000, 10_000, 20_000, 60_000];
let retries = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let watchingOnline = false;
// inbuxa: a refresh that failed without a refusal is tried again on its own:
// after 5, 10 and 20 seconds, then every minute, and at once when the browser
// says it is back online.
const RETRY_DELAYS = [5_000, 10_000, 20_000, 60_000];
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0;
let listeningForOnline = false;
/** Try the refresh again now, as the retry timer, the browser coming online or the reader would. */
export function retryRefresh(): void {
if (!useAuthStore.getState().refreshToken) {
stopRetrying();
return;
}
refreshAccessToken().catch(() => {});
}
function serverUnreachable(): void {
function cantReachServer(): void {
useConnectionStore.getState().setUnreachable(true);
if (!listeningForOnline) {
listeningForOnline = true;
if (!watchingOnline) {
watchingOnline = true;
window.addEventListener('online', () => {
if (useConnectionStore.getState().unreachable) retryRefresh();
});
}
if (retryTimer) return;
const delay = RETRY_DELAYS[Math.min(retryCount, RETRY_DELAYS.length - 1)];
retryCount += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
retryRefresh();
}, delay);
retryTimer = setTimeout(
() => {
retryTimer = undefined;
retryRefresh();
},
RETRY_AFTER_MS[Math.min(retries++, RETRY_AFTER_MS.length - 1)],
);
}
function stopRetrying(): void {
retryCount = 0;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
function reachedServer(): void {
retries = 0;
clearTimeout(retryTimer);
retryTimer = undefined;
useConnectionStore.getState().setUnreachable(false);
}
export async function refreshAccessToken(): Promise<void> {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const { refreshToken, tokenEndpoint } = useAuthStore.getState();
if (!refreshToken || !tokenEndpoint) {
stopRetrying();
endSession();
throw new Error('No refresh token or token endpoint available');
}
const clientId = getOAuthClientId();
let response: Response;
try {
response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
}),
});
} catch (error) {
// No answer: a dropped connection, the server restarting, a laptop waking up.
serverUnreachable();
throw error;
}
if (!response.ok) {
if (isRefusal(response.status)) {
stopRetrying();
endSession();
} else {
serverUnreachable();
}
throw new Error(`Token refresh failed: ${response.status}`);
}
let data: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
try {
data = await response.json();
} catch (error) {
// A proxy's error page rather than the server's answer.
serverUnreachable();
throw error;
}
if (typeof data.access_token !== 'string' || typeof data.expires_in !== 'number') {
serverUnreachable();
throw new Error('Token refresh answered without a token');
}
useAuthStore
.getState()
.setTokens(
data.access_token,
typeof data.refresh_token === 'string' ? data.refresh_token : refreshToken,
data.expires_in,
tokenEndpoint,
);
stopRetrying();
})();
try {
await refreshPromise;
} finally {
refreshPromise = null;
}
/** Tries the refresh again now: the retry timer, the browser coming back online, or the reader's "Try now". */
export function retryRefresh(): void {
if (useAuthStore.getState().refreshToken) refreshAccessToken().catch(() => {});
else reachedServer();
}
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
const store = useAuthStore.getState();
// --- Refreshing the access token --------------------------------------------
if (store.isTokenExpiringSoon() && store.refreshToken) {
/** The token endpoint turned the refresh token down (spent, revoked, the account locked): the session is over. */
const isRefusal = (status: number) => status === 400 || status === 401 || status === 403;
async function refreshOnce(): Promise<void> {
const { refreshToken, tokenEndpoint } = useAuthStore.getState();
if (!refreshToken || !tokenEndpoint) {
reachedServer();
endSession();
throw new Error('No refresh token or token endpoint available');
}
let res: Response;
try {
res = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: getOAuthClientId(),
}),
});
} catch (err) {
// No answer: a dropped connection, the server restarting, a laptop waking up.
cantReachServer();
throw err;
}
if (!res.ok) {
if (isRefusal(res.status)) {
reachedServer();
endSession();
} else {
cantReachServer();
}
throw new Error(`Token refresh failed: ${res.status}`);
}
let answer: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
try {
answer = await res.json();
} catch (err) {
// A proxy's error page, not the server's answer.
cantReachServer();
throw err;
}
if (typeof answer.access_token !== 'string' || typeof answer.expires_in !== 'number') {
cantReachServer();
throw new Error('Token refresh answered without a token');
}
// The server may rotate the refresh token; if it didn't send one, the old one stays good.
const nextRefresh = typeof answer.refresh_token === 'string' ? answer.refresh_token : refreshToken;
useAuthStore.getState().setTokens(answer.access_token, nextRefresh, answer.expires_in, tokenEndpoint);
reachedServer();
}
let refreshing: Promise<void> | null = null;
/** Refreshes the access token; callers that ask while a refresh is under way share it. */
export async function refreshAccessToken(): Promise<void> {
refreshing ??= refreshOnce().finally(() => {
refreshing = null;
});
return refreshing;
}
// --- Requests ---------------------------------------------------------------
/**
* Fetches `path` on the server with the access token. Refreshes first when
* the token is about to run out, and once more on a 401. Throws ApiError for
* any error status.
*/
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
const auth = useAuthStore.getState();
if (auth.isTokenExpiringSoon() && auth.refreshToken) {
try {
await refreshAccessToken();
} catch (error) {
// inbuxa: not refreshed yet, but a token that hasn't run out still works.
if (!useAuthStore.getState().isAuthenticated()) throw error;
} catch (err) {
// Not refreshed yet, but a token that hasn't run out still works.
if (!useAuthStore.getState().isAuthenticated()) throw err;
}
}
const makeRequest = async (): Promise<Response> => {
const { accessToken } = useAuthStore.getState();
const url = `${getApiBaseUrl()}${path}`;
const send = () => {
const headers = new Headers(options?.headers);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const response = await fetch(url, {
...options,
headers,
redirect: 'follow',
});
return response;
const { accessToken } = useAuthStore.getState();
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
return fetch(`${getApiBaseUrl()}${path}`, { ...options, headers, redirect: 'follow' });
};
let response = await makeRequest();
if (response.status === 401) {
if (useAuthStore.getState().refreshToken) {
try {
await refreshAccessToken();
} catch (error) {
// inbuxa: the server can't be reached; that, not a 401, is what happened.
if (useConnectionStore.getState().unreachable) throw error;
throw new ApiError(401, 'Unauthorized', null);
}
response = await makeRequest();
} else {
let res = await send();
if (res.status === 401) {
if (!useAuthStore.getState().refreshToken) {
endSession();
throw new ApiError(401, 'Unauthorized', null);
}
}
if (!response.ok) {
let body: unknown = null;
try {
body = await response.json();
// eslint-disable-next-line no-empty
} catch {}
throw new ApiError(response.status, response.statusText, body);
await refreshAccessToken();
} catch (err) {
// If the server can't be reached, say that, not 401.
if (useConnectionStore.getState().unreachable) throw err;
throw new ApiError(401, 'Unauthorized', null);
}
res = await send();
}
return response;
if (!res.ok) {
const body: unknown = await res.json().catch(() => null);
throw new ApiError(res.status, res.statusText, body);
}
return res;
}
+121
View File
@@ -0,0 +1,121 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
exchangeCode: vi.fn(),
setTokens: vi.fn(),
basePath: '',
}));
vi.mock('@/lib/basePath', () => ({ getBasePath: () => mocks.basePath }));
vi.mock('@/stores/authStore', () => ({ useAuthStore: { getState: () => ({ setTokens: mocks.setTokens }) } }));
vi.mock('./oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('./oauth')>()),
exchangeCode: mocks.exchangeCode,
getOAuthRedirectUri: () => 'https://admin.example.org/oauth/callback',
}));
import { finishSignIn, landingPath } from './callback';
import { getStoredOAuthData } from './oauth';
// In memory: Node's own `sessionStorage` global, empty without a storage file, hides happy-dom's.
beforeAll(() => {
const store = new Map<string, string>();
vi.stubGlobal('sessionStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
/** What startAuthFlow would have kept for this browser's sign-in. */
function keep(overrides: Record<string, string> = {}) {
const kept: Record<string, string> = {
code_verifier: 'verifier',
token_endpoint: 'https://mail.example.org/auth/token',
state: 'abc123',
return_url: '/Settings/x:Domain',
end_session_endpoint: 'https://mail.example.org/logout',
...overrides,
};
for (const [k, v] of Object.entries(kept)) sessionStorage.setItem(`inbuxa-oauth-${k}`, v);
}
const query = (q: string) => new URLSearchParams(q);
const nothingKept = () => Object.values(getStoredOAuthData()).every((v) => v === null);
beforeEach(() => {
sessionStorage.clear();
mocks.exchangeCode.mockReset().mockResolvedValue({ access_token: 'A', refresh_token: 'R', expires_in: 300 });
mocks.setTokens.mockReset();
mocks.basePath = '';
});
describe('finishSignIn', () => {
it('trades the code, keeps the tokens and goes back where the admin was', async () => {
keep();
expect(await finishSignIn(query('code=c0de&state=abc123'))).toBe('/Settings/x:Domain');
expect(mocks.exchangeCode).toHaveBeenCalledWith(
'c0de',
'verifier',
'https://mail.example.org/auth/token',
'https://admin.example.org/oauth/callback',
);
expect(mocks.setTokens).toHaveBeenCalledWith(
'A',
'R',
300,
'https://mail.example.org/auth/token',
'https://mail.example.org/logout',
);
expect(nothingKept()).toBe(true);
});
it("refuses a callback this browser didn't start", async () => {
keep();
await expect(finishSignIn(query('code=c0de&state=forged'))).rejects.toThrow('State parameter mismatch');
sessionStorage.clear();
await expect(finishSignIn(query('code=c0de&state=abc123'))).rejects.toThrow('State parameter mismatch');
expect(mocks.exchangeCode).not.toHaveBeenCalled();
});
it("shows the server's error, or says what is missing", async () => {
keep();
await expect(finishSignIn(query('error=access_denied&error_description=Wrong+password'))).rejects.toThrow(
'Wrong password',
);
keep();
await expect(finishSignIn(query('error=access_denied'))).rejects.toThrow('access_denied');
keep();
await expect(finishSignIn(query('state=abc123'))).rejects.toThrow('Missing authorization code');
keep({ code_verifier: '' });
await expect(finishSignIn(query('code=c&state=abc123'))).rejects.toThrow('Missing OAuth session data');
expect(mocks.exchangeCode).not.toHaveBeenCalled();
});
it('forgets the sign-in after a failed trade too', async () => {
keep();
mocks.exchangeCode.mockRejectedValue(new Error('Token exchange failed: 400 Bad Request'));
await expect(finishSignIn(query('code=c&state=abc123'))).rejects.toThrow('400');
expect(mocks.setTokens).not.toHaveBeenCalled();
expect(nothingKept()).toBe(true);
});
});
describe('landingPath', () => {
it('drops the base path, and lands on the start page instead of a sign-in page', () => {
expect(landingPath(null)).toBe('/');
expect(landingPath('/Management/x:Account?q=1')).toBe('/Management/x:Account?q=1');
expect(landingPath('/login')).toBe('/');
expect(landingPath('/oauth/callback?code=1')).toBe('/');
mocks.basePath = '/admin';
expect(landingPath('/admin/Settings')).toBe('/Settings');
expect(landingPath('/admin')).toBe('/');
expect(landingPath('/admin/login?x')).toBe('/');
});
});
+63
View File
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** The second half of signing in: what /oauth/callback does with the server's answer. */
import i18n from '@/i18n';
import { getBasePath } from '@/lib/basePath';
import { useAuthStore } from '@/stores/authStore';
import { clearStoredOAuthData, exchangeCode, getOAuthRedirectUri, getStoredOAuthData } from './oauth';
/** A path inside the console, without its base, and never a sign-in page (those land on '/'). */
export function landingPath(returnUrl: string | null): string {
const base = getBasePath();
let path = returnUrl || '/';
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
const isSignInPage = ['/login', '/oauth/callback'].some((p) => path === p || path.startsWith(`${p}?`));
return isSignInPage ? '/' : path;
}
/**
* Checks the callback's parameters against the sign-in this browser started,
* trades the code for tokens and keeps them, and returns where to go next.
* Throws, with a message for the reader, when the server reported an error,
* a parameter is missing, the state doesn't match (a callback this browser
* didn't ask for), or the sign-in data is gone. Either way, the sign-in data
* is cleared: a code and verifier are good for one try.
*/
export async function finishSignIn(params: URLSearchParams): Promise<string> {
const t = i18n.t.bind(i18n);
try {
const failure = params.get('error');
if (failure) throw new Error(params.get('error_description') || failure);
const code = params.get('code');
const state = params.get('state');
if (!code || !state) throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter'));
const kept = getStoredOAuthData();
if (!kept.state || kept.state !== state) {
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
}
if (!kept.codeVerifier || !kept.tokenEndpoint) {
throw new Error(t('oauth.missingData', 'Missing OAuth session data. Please try logging in again.'));
}
const tokens = await exchangeCode(code, kept.codeVerifier, kept.tokenEndpoint, getOAuthRedirectUri());
useAuthStore
.getState()
.setTokens(
tokens.access_token,
tokens.refresh_token,
tokens.expires_in,
kept.tokenEndpoint,
kept.endSessionEndpoint,
);
return landingPath(kept.returnUrl);
} finally {
clearStoredOAuthData();
}
}
+204 -120
View File
@@ -1,157 +1,241 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 } from 'vitest';
import { generateCodeVerifier, generateCodeChallenge } from './oauth';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const UNRESERVED_RE = /^[A-Za-z0-9\-._~]+$/;
import {
buildEndSessionUrl,
clearStoredOAuthData,
discover,
exchangeCode,
generateCodeChallenge,
generateCodeVerifier,
getStoredOAuthData,
startAuthFlow,
} from './oauth';
describe('generateCodeVerifier', () => {
it('defaults to 64 characters', () => {
const verifier = generateCodeVerifier();
expect(verifier).toHaveLength(64);
const SERVER = 'https://mail.example.org';
const CONSOLE = 'https://admin.example.org';
// In memory: Node's own `sessionStorage` global, empty without a storage file, hides happy-dom's.
beforeAll(() => {
const store = new Map<string, string>();
vi.stubGlobal('sessionStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
it('honours a custom length', () => {
beforeEach(() => {
document.head.innerHTML = `<meta name="api-base-url" content="${SERVER}" />`;
sessionStorage.clear();
});
/** Answers fetches from a table of URL → JSON (or a status), recording what was asked. */
function serve(routes: Record<string, unknown>) {
const calls: { url: string; init?: RequestInit }[] = [];
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
calls.push({ url, init });
const answer = routes[url];
if (answer === undefined) return new Response('', { status: 404, statusText: 'Not Found' });
if (typeof answer === 'number') return new Response('', { status: answer, statusText: 'Nope' });
return new Response(JSON.stringify(answer), { status: 200 });
});
return calls;
}
/** Stands in for window.location, capturing where the browser is sent. */
function pretendLocation(pathname = '/Settings/x:Domain', search = '') {
const original = window.location;
const sentTo = { href: '' };
Object.defineProperty(window, 'location', {
configurable: true,
value: {
origin: CONSOLE,
pathname,
search,
get href() {
return sentTo.href;
},
set href(v: string) {
sentTo.href = v;
},
},
});
return { sentTo, restore: () => Object.defineProperty(window, 'location', { configurable: true, value: original }) };
}
afterEach(() => vi.unstubAllGlobals());
describe('code verifier', () => {
it('is 64 characters by default, or the length asked for within 43–128', () => {
expect(generateCodeVerifier()).toHaveLength(64);
expect(generateCodeVerifier(43)).toHaveLength(43);
expect(generateCodeVerifier(128)).toHaveLength(128);
for (const bad of [42, 129, 50.5]) expect(() => generateCodeVerifier(bad)).toThrow(/43-128/);
});
it('rejects lengths outside RFC 7636 range', () => {
expect(() => generateCodeVerifier(42)).toThrow();
expect(() => generateCodeVerifier(129)).toThrow();
});
it('returns different values on each call', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toBe(b);
});
it('only contains RFC 3986 unreserved characters', () => {
const verifier = generateCodeVerifier();
expect(verifier).toMatch(UNRESERVED_RE);
});
it('consistently produces unreserved-only output across multiple calls', () => {
for (let i = 0; i < 20; i++) {
const v = generateCodeVerifier();
expect(v).toMatch(UNRESERVED_RE);
expect(v).toHaveLength(64);
}
});
it('exercises every character in the alphabet given enough samples', () => {
const seen = new Set<string>();
for (let i = 0; i < 50; i++) {
for (const ch of generateCodeVerifier(128)) seen.add(ch);
}
for (const ch of '-._~') {
expect(seen.has(ch)).toBe(true);
}
it('uses only unreserved characters, all of them over enough samples, never repeating', () => {
const samples = Array.from({ length: 200 }, () => generateCodeVerifier(128));
for (const v of samples) expect(v).toMatch(/^[A-Za-z0-9\-._~]+$/);
expect(new Set(samples).size).toBe(samples.length);
expect(new Set(samples.join('')).size).toBe(66);
});
});
describe('generateCodeChallenge', () => {
it('returns a base64url string without +, /, or =', async () => {
const { challenge } = await generateCodeChallenge('test-verifier');
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces consistent output for same input', async () => {
const a = await generateCodeChallenge('same-input');
const b = await generateCodeChallenge('same-input');
expect(a).toEqual(b);
});
it('produces different output for different inputs', async () => {
const a = await generateCodeChallenge('input-one');
const b = await generateCodeChallenge('input-two');
expect(a.challenge).not.toBe(b.challenge);
});
it('returns a non-empty string', async () => {
const { challenge } = await generateCodeChallenge('anything');
expect(challenge.length).toBeGreaterThan(0);
});
it('produces a SHA-256 sized output (43 base64url chars for 32 bytes) when S256 is used', async () => {
const { challenge, method } = await generateCodeChallenge('test');
expect(method).toBe('S256');
expect(challenge).toHaveLength(43);
});
it('still uses S256, with the same challenge, when crypto.subtle is unavailable', async () => {
const verifier = generateCodeVerifier();
const withSubtle = await generateCodeChallenge(verifier);
const originalSubtle = crypto.subtle;
Object.defineProperty(crypto, 'subtle', {
configurable: true,
get: () => undefined,
describe('code challenge', () => {
it("matches RFC 7636's worked example", async () => {
expect(await generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toEqual({
challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
method: 'S256',
});
});
it('is the same S256 challenge where crypto.subtle is missing (plain HTTP)', async () => {
const verifier = generateCodeVerifier();
const expected = await generateCodeChallenge(verifier);
const subtle = crypto.subtle;
Object.defineProperty(crypto, 'subtle', { configurable: true, get: () => undefined });
try {
const { challenge, method } = await generateCodeChallenge(verifier);
expect(method).toBe('S256');
expect(challenge).toBe(withSubtle.challenge);
expect(challenge).not.toBe(verifier);
expect(await generateCodeChallenge(verifier)).toEqual(expected);
} finally {
Object.defineProperty(crypto, 'subtle', {
configurable: true,
value: originalSubtle,
});
Object.defineProperty(crypto, 'subtle', { configurable: true, value: subtle });
}
});
});
describe('startAuthFlow (INBUXA: no account-name step)', () => {
const metadata = {
describe('discover', () => {
it("reads the server's OpenID configuration, putting relative endpoints on the server", async () => {
const calls = serve({
[`${SERVER}/.well-known/openid-configuration`]: {
authorization_endpoint: '/login',
token_endpoint: `${SERVER}/auth/token`,
end_session_endpoint: '/logout',
},
});
expect(await discover()).toMatchObject({
authorization_endpoint: `${SERVER}/login`,
token_endpoint: `${SERVER}/auth/token`,
end_session_endpoint: `${SERVER}/logout`,
});
expect(calls.map((c) => c.url)).toEqual([`${SERVER}/.well-known/openid-configuration`]);
});
it('asks per account when given a name, and says what failed', async () => {
const calls = serve({ [`${SERVER}/api/discover/ann%40example.org`]: 503 });
await expect(discover('[email protected]')).rejects.toThrow('Discovery failed for "[email protected]": 503 Nope');
expect(calls[0].url).toBe(`${SERVER}/api/discover/ann%40example.org`);
});
});
describe('startAuthFlow', () => {
const config = {
authorization_endpoint: '/login',
token_endpoint: '/auth/token',
scopes_supported: ['openid', 'offline_access'],
end_session_endpoint: '/logout',
scopes_supported: ['openid', 'profile', 'offline_access', 'urn:ietf:params:jmap:core'],
};
async function run(username: string | null) {
document.head.innerHTML = '<meta name="api-base-url" content="https://mail.example.org" />';
const requested: string[] = [];
const fetchMock = async (url: string) => {
requested.push(url);
return new Response(JSON.stringify(metadata), { status: 200 });
};
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock as typeof fetch;
let href = '';
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, origin: 'https://admin.example.org', pathname: '/', search: '', set href(v: string) { href = v; }, get href() { return href; } },
async function signIn(username: string | null, returnUrl?: string | null, path?: string) {
serve({
[`${SERVER}/.well-known/openid-configuration`]: config,
[`${SERVER}/api/discover/${encodeURIComponent(username ?? '')}`]: config,
});
const loc = pretendLocation(path);
try {
const { startAuthFlow } = await import('./oauth');
await startAuthFlow(username);
await startAuthFlow(username, returnUrl);
} finally {
globalThis.fetch = originalFetch;
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
loc.restore();
}
return { requested, target: new URL(href) };
return new URL(loc.sentTo.href);
}
it('reads the server-wide configuration and goes straight to its sign-in page', async () => {
const { requested, target } = await run(null);
expect(requested).toEqual(['https://mail.example.org/.well-known/openid-configuration']);
expect(`${target.origin}${target.pathname}`).toBe('https://mail.example.org/login');
expect(target.searchParams.has('login_hint')).toBe(false);
expect(target.searchParams.get('redirect_uri')).toBe('https://admin.example.org/oauth/callback');
it("sends the browser to the server's sign-in page with a PKCE challenge it can check", async () => {
const to = await signIn(null);
const stored = getStoredOAuthData();
expect(`${to.origin}${to.pathname}`).toBe(`${SERVER}/login`);
expect(Object.fromEntries(to.searchParams)).toEqual({
response_type: 'code',
client_id: 'inbuxa-admin',
redirect_uri: `${CONSOLE}/oauth/callback`,
code_challenge: (await generateCodeChallenge(stored.codeVerifier!)).challenge,
code_challenge_method: 'S256',
state: stored.state,
prompt: 'login',
scope: 'openid profile offline_access',
});
expect(stored.state).toMatch(/^[0-9a-f]{32}$/);
expect(stored).toMatchObject({
tokenEndpoint: `${SERVER}/auth/token`,
endSessionEndpoint: `${SERVER}/logout`,
returnUrl: '/Settings/x:Domain',
});
});
it('still looks an account up, and passes the hint, when given one', async () => {
const { requested, target } = await run('someone@example.org');
expect(requested).toEqual(['https://mail.example.org/api/discover/someone%40example.org']);
expect(target.searchParams.get('login_hint')).toBe('[email protected]');
it('prefills the account when given one', async () => {
expect((await signIn('[email protected]')).searchParams.get('login_hint')).toBe('ann@example.org');
});
it('comes back to the page asked for, but never to the sign-in pages', async () => {
await signIn(null, '/Management/x:Account?q=ann');
expect(getStoredOAuthData().returnUrl).toBe('/Management/x:Account?q=ann');
await signIn(null, null, '/login');
expect(getStoredOAuthData().returnUrl).toBe('');
await signIn(null, '/oauth/callback?code=x');
expect(getStoredOAuthData().returnUrl).toBe('');
});
it('forgets an end-session endpoint the server no longer offers', async () => {
await signIn(null);
config.end_session_endpoint = '';
try {
await signIn(null);
} finally {
config.end_session_endpoint = '/logout';
}
expect(getStoredOAuthData().endSessionEndpoint).toBeNull();
});
it('clears what it kept', async () => {
await signIn(null);
clearStoredOAuthData();
expect(Object.values(getStoredOAuthData()).every((v) => v === null)).toBe(true);
});
});
describe('exchangeCode', () => {
it('posts the code, verifier and redirect URI, and returns the tokens', async () => {
const tokens = { access_token: 'a', refresh_token: 'r', expires_in: 3600, token_type: 'Bearer' };
const calls = serve({ [`${SERVER}/auth/token`]: tokens });
expect(await exchangeCode('c0de', 'v3rifier', `${SERVER}/auth/token`, `${CONSOLE}/oauth/callback`)).toEqual(tokens);
expect(calls[0].init?.method).toBe('POST');
expect(Object.fromEntries(new URLSearchParams(String(calls[0].init?.body)))).toEqual({
grant_type: 'authorization_code',
code: 'c0de',
code_verifier: 'v3rifier',
client_id: 'inbuxa-admin',
redirect_uri: `${CONSOLE}/oauth/callback`,
});
});
it('says why a refused trade failed', async () => {
serve({ [`${SERVER}/auth/token`]: 400 });
await expect(exchangeCode('c', 'v', `${SERVER}/auth/token`, 'x')).rejects.toThrow(
'Token exchange failed: 400 Nope',
);
});
});
describe('buildEndSessionUrl', () => {
it('adds the client and where to come back to, after any query already there', () => {
expect(buildEndSessionUrl(`${SERVER}/logout`, `${CONSOLE}/login`)).toBe(
`${SERVER}/logout?client_id=inbuxa-admin&post_logout_redirect_uri=${encodeURIComponent(`${CONSOLE}/login`)}`,
);
expect(buildEndSessionUrl(`${SERVER}/logout?x=1`, 'y')).toMatch(/\/logout\?x=1&client_id=/);
});
});
+195 -201
View File
@@ -1,21 +1,23 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 { getApiBaseUrl } from '@/services/api';
/**
* Signing in with OAuth 2.0: the authorization code flow with PKCE (RFC
* 7636, always S256), against the server's own sign-in page.
*
* 1. startAuthFlow() finds the server's endpoints, keeps the verifier, state
* and return path in sessionStorage, and sends the browser to sign in.
* 2. The server sends it back to /oauth/callback with a code, which
* OAuthCallback trades for tokens with exchangeCode().
*/
import i18n from '@/i18n';
import { getBasePath } from '@/lib/basePath';
import { getOAuthClientId } from '@/lib/oauthClientId';
import { sha256 } from '@/lib/sha256';
import i18n from '@/i18n';
const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
const SESSION_PREFIX = 'inbuxa-oauth-';
import { getApiBaseUrl } from '@/services/api';
interface DiscoveryResponse {
authorization_endpoint: string;
@@ -24,88 +26,6 @@ interface DiscoveryResponse {
scopes_supported?: string[];
}
/**
* The server's OAuth endpoints.
*
* Upstream asks for the account name first and looks the endpoints up per
* account, because a domain there can sign in at its own identity provider.
* INBUXA Admin talks to one known server whose own sign-in page handles every
* account, so it reads the server's published OpenID configuration instead,
* which needs no account name. Given one, it still asks per account.
*/
export async function discover(username?: string | null): Promise<DiscoveryResponse> {
const url = username
? `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`
: `${getApiBaseUrl()}/.well-known/openid-configuration`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
username: username ?? getApiBaseUrl(),
status: response.status,
statusText: response.statusText,
}),
);
}
const discovered = (await response.json()) as DiscoveryResponse;
// A server that doesn't know its public URL yet (bootstrap mode, or no
// public URL configured) returns relative endpoints such as "/login". They
// belong to the server, so resolve them against the server's address, not
// this page's: INBUXA Admin is usually served from somewhere else.
const base = `${getApiBaseUrl()}/`;
const absolute = (endpoint: string | undefined) => (endpoint ? new URL(endpoint, base).toString() : endpoint);
return {
...discovered,
authorization_endpoint: absolute(discovered.authorization_endpoint) as string,
token_endpoint: absolute(discovered.token_endpoint) as string,
end_session_endpoint: absolute(discovered.end_session_endpoint),
};
}
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
export function generateCodeVerifier(length: number = 64): string {
if (length < 43 || length > 128) {
throw new Error(`code_verifier length must be 43-128, got ${length}`);
}
const out: string[] = [];
while (out.length < length) {
const buf = new Uint8Array(length * 2);
crypto.getRandomValues(buf);
for (let i = 0; i < buf.length && out.length < length; i++) {
const b = buf[i];
if (b < 198) {
out.push(UNRESERVED[b % 66]);
}
}
}
return out.join('');
}
// inbuxa: always S256. Over plain HTTP, where browsers leave out
// crypto.subtle, the digest comes from our own SHA-256 instead of falling
// back to `plain`, which would send the verifier itself as the challenge.
export async function generateCodeChallenge(verifier: string): Promise<{ challenge: string; method: 'S256' }> {
const data = new TextEncoder().encode(verifier);
const digest =
typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
? new Uint8Array(await crypto.subtle.digest('SHA-256', data))
: sha256(data);
return { challenge: base64UrlEncode(digest), method: 'S256' };
}
function generateState(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function base64UrlEncode(bytes: Uint8Array): string {
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');
return btoa(binString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
interface TokenResponse {
access_token: string;
refresh_token: string;
@@ -113,130 +33,204 @@ interface TokenResponse {
token_type: string;
}
/**
* The server's OAuth endpoints, from its published OpenID configuration. The
* console talks to one server whose sign-in page serves every account, so no
* account name is needed; given one, the server's per-account lookup answers.
*
* A server that doesn't know its public URL yet (setup mode) answers with
* relative endpoints such as "/login". Those are the server's, so they are
* resolved against the server's address, not the console's.
*/
export async function discover(username?: string | null): Promise<DiscoveryResponse> {
const server = getApiBaseUrl();
const res = await fetch(
username ? `${server}/api/discover/${encodeURIComponent(username)}` : `${server}/.well-known/openid-configuration`,
);
if (!res.ok) {
throw new Error(
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
username: username ?? server,
status: res.status,
statusText: res.statusText,
}),
);
}
const found = (await res.json()) as DiscoveryResponse;
const onServer = (url: string) => new URL(url, `${server}/`).toString();
return {
...found,
authorization_endpoint: onServer(found.authorization_endpoint),
token_endpoint: onServer(found.token_endpoint),
end_session_endpoint: found.end_session_endpoint ? onServer(found.end_session_endpoint) : undefined,
};
}
// RFC 3986's unreserved characters, the only ones a code verifier may use.
const VERIFIER_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
// Random bytes at or above this are dropped, so every character is equally likely.
const UNBIASED_BELOW = 256 - (256 % VERIFIER_ALPHABET.length);
/** A random PKCE code verifier, 43–128 characters long. */
export function generateCodeVerifier(length = 64): string {
if (!Number.isInteger(length) || length < 43 || length > 128) {
throw new Error(`code_verifier length must be 43-128, got ${length}`);
}
let verifier = '';
while (verifier.length < length) {
for (const byte of crypto.getRandomValues(new Uint8Array(length))) {
if (byte < UNBIASED_BELOW && verifier.length < length) {
verifier += VERIFIER_ALPHABET[byte % VERIFIER_ALPHABET.length];
}
}
}
return verifier;
}
function base64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
}
/**
* The S256 challenge for a verifier. Browsers leave out crypto.subtle on
* plain-HTTP pages (a console on a LAN address); there the digest comes from
* our own SHA-256, never the `plain` method, which would send the verifier
* itself.
*/
export async function generateCodeChallenge(verifier: string): Promise<{ challenge: string; method: 'S256' }> {
const bytes = new TextEncoder().encode(verifier);
const digest = globalThis.crypto?.subtle
? new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
: sha256(bytes);
return { challenge: base64Url(digest), method: 'S256' };
}
/** 128 random bits, as hex: ties the callback to the sign-in this browser started. */
function newState(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
}
// What a sign-in in progress keeps until the callback, in sessionStorage
// under `inbuxa-oauth-<name>`.
const PENDING = ['code_verifier', 'token_endpoint', 'state', 'return_url', 'end_session_endpoint'] as const;
type Pending = (typeof PENDING)[number];
const pendingKey = (name: Pending) => `inbuxa-oauth-${name}`;
export function getStoredOAuthData() {
const read = (name: Pending) => sessionStorage.getItem(pendingKey(name));
return {
codeVerifier: read('code_verifier'),
tokenEndpoint: read('token_endpoint'),
state: read('state'),
returnUrl: read('return_url'),
endSessionEndpoint: read('end_session_endpoint'),
};
}
export function clearStoredOAuthData(): void {
for (const name of PENDING) sessionStorage.removeItem(pendingKey(name));
}
const consoleUrl = (path: string) => `${window.location.origin}${getBasePath()}${path}`;
/** Where the server sends the browser back after sign-in. */
export const getOAuthRedirectUri = (): string => consoleUrl('/oauth/callback');
/** Where the server sends the browser after signing out there. */
export const getPostLogoutRedirectUri = (): string => consoleUrl('/login');
/** The page to come back to: the one given, else the current one, but never the sign-in pages themselves. */
function returnPath(requested: string | null | undefined): string {
const path = requested ?? window.location.pathname + window.location.search;
const base = getBasePath();
const route = path.startsWith(base) ? path.slice(base.length) : path;
const isSignInPage = ['/login', '/oauth/callback'].some((p) => route === p || route.startsWith(`${p}?`));
return isSignInPage ? '' : path;
}
/** The scopes to ask for: VITE_OAUTH_SCOPES if set, else the OpenID ones the server offers. */
function scopesFor(offered: string[] | undefined): string {
const configured = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
if (configured) return configured;
if (!offered?.includes('openid')) return '';
return ['openid', 'email', 'profile', 'offline_access'].filter((s) => offered.includes(s)).join(' ');
}
/**
* Sends the browser to the server's sign-in page. `username` prefills it;
* `returnUrl` is where to land afterwards (the current page if left out).
*/
export async function startAuthFlow(username: string | null, returnUrl?: string | null): Promise<void> {
const endpoints = await discover(username);
const verifier = generateCodeVerifier();
const { challenge, method } = await generateCodeChallenge(verifier);
const state = newState();
const pending: Record<Pending, string | undefined> = {
code_verifier: verifier,
token_endpoint: endpoints.token_endpoint,
state,
return_url: returnPath(returnUrl),
end_session_endpoint: endpoints.end_session_endpoint,
};
for (const name of PENDING) {
const value = pending[name];
if (value === undefined)
sessionStorage.removeItem(pendingKey(name)); // no end-session endpoint
else sessionStorage.setItem(pendingKey(name), value);
}
const query = new URLSearchParams({
response_type: 'code',
client_id: getOAuthClientId(),
redirect_uri: getOAuthRedirectUri(),
code_challenge: challenge,
code_challenge_method: method,
state,
// Always ask for the password: a console session should never start silently.
prompt: 'login',
});
if (username) query.set('login_hint', username);
const scope = scopesFor(endpoints.scopes_supported);
if (scope) query.set('scope', scope);
window.location.href = `${endpoints.authorization_endpoint}?${query}`;
}
/** Trades the code from the callback for tokens. */
export async function exchangeCode(
code: string,
codeVerifier: string,
tokenEndpoint: string,
redirectUri: string,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: getOAuthClientId(),
redirect_uri: redirectUri,
});
const response = await fetch(tokenEndpoint, {
const res = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: getOAuthClientId(),
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
if (!res.ok) {
throw new Error(
i18n.t('oauth.tokenExchangeFailed', 'Token exchange failed: {{status}} {{statusText}}', {
status: response.status,
statusText: response.statusText,
status: res.status,
statusText: res.statusText,
}),
);
}
return response.json() as Promise<TokenResponse>;
}
function getRedirectUri(): string {
const basePath = getBasePath();
return `${window.location.origin}${basePath}/oauth/callback`;
}
export async function startAuthFlow(username: string | null, returnUrl?: string | null): Promise<void> {
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username);
const codeVerifier = generateCodeVerifier();
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
const state = generateState();
const candidate = returnUrl ?? window.location.pathname + window.location.search;
const basePath = getBasePath();
const stripped = candidate.startsWith(basePath) ? candidate.slice(basePath.length) : candidate;
const isAuthPath =
stripped === '/login' ||
stripped.startsWith('/login?') ||
stripped === '/oauth/callback' ||
stripped.startsWith('/oauth/callback?');
const safeReturnUrl = isAuthPath ? '' : candidate;
sessionStorage.setItem(`${SESSION_PREFIX}code_verifier`, codeVerifier);
sessionStorage.setItem(`${SESSION_PREFIX}token_endpoint`, token_endpoint);
sessionStorage.setItem(`${SESSION_PREFIX}state`, state);
sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl);
if (end_session_endpoint) {
sessionStorage.setItem(`${SESSION_PREFIX}end_session_endpoint`, end_session_endpoint);
} else {
sessionStorage.removeItem(`${SESSION_PREFIX}end_session_endpoint`);
}
const params = new URLSearchParams({
response_type: 'code',
client_id: getOAuthClientId(),
redirect_uri: getRedirectUri(),
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
state,
prompt: 'login',
});
if (username) {
params.set('login_hint', username);
}
let scope: string;
if (SCOPES && SCOPES.length > 0) {
scope = SCOPES;
} else if (scopes_supported?.includes('openid')) {
scope = ['openid', 'email', 'profile', 'offline_access'].filter((s) => scopes_supported.includes(s)).join(' ');
} else {
scope = '';
}
if (scope) {
params.set('scope', scope);
}
window.location.href = `${authorization_endpoint}?${params.toString()}`;
}
export function getStoredOAuthData() {
return {
codeVerifier: sessionStorage.getItem(`${SESSION_PREFIX}code_verifier`),
tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`),
state: sessionStorage.getItem(`${SESSION_PREFIX}state`),
returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`),
endSessionEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}end_session_endpoint`),
};
}
export function clearStoredOAuthData(): void {
sessionStorage.removeItem(`${SESSION_PREFIX}code_verifier`);
sessionStorage.removeItem(`${SESSION_PREFIX}token_endpoint`);
sessionStorage.removeItem(`${SESSION_PREFIX}state`);
sessionStorage.removeItem(`${SESSION_PREFIX}return_url`);
sessionStorage.removeItem(`${SESSION_PREFIX}end_session_endpoint`);
}
export function getOAuthRedirectUri(): string {
return getRedirectUri();
}
export function getPostLogoutRedirectUri(): string {
const basePath = getBasePath();
return `${window.location.origin}${basePath}/login`;
return (await res.json()) as TokenResponse;
}
/** The server's end-session URL, sending the browser back to sign-in afterwards. */
export function buildEndSessionUrl(endSessionEndpoint: string, postLogoutRedirectUri: string): string {
const params = new URLSearchParams({
client_id: getOAuthClientId(),
post_logout_redirect_uri: postLogoutRedirectUri,
});
const sep = endSessionEndpoint.includes('?') ? '&' : '?';
return `${endSessionEndpoint}${sep}${params.toString()}`;
const query = new URLSearchParams({ client_id: getOAuthClientId(), post_logout_redirect_uri: postLogoutRedirectUri });
return `${endSessionEndpoint}${endSessionEndpoint.includes('?') ? '&' : '?'}${query}`;
}