Rewrite sign-in, the callback and the API wrapper #124
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user