mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: sign in through the bulwark-mobile client on stalwart 1.0 (bulwarkmail/webmail#1459)
This commit is contained in:
@@ -17,8 +17,10 @@
|
|||||||
* started.
|
* started.
|
||||||
*
|
*
|
||||||
* What differs between the lines (checked on 0.16.27 and 1.0.0):
|
* What differs between the lines (checked on 0.16.27 and 1.0.0):
|
||||||
* - 1.0 knows only registered clients, so the app registers its own public
|
* - 1.0 knows only registered clients, so the app signs in as
|
||||||
* client (RFC 7591); 0.16 takes `bulwark-webmail`.
|
* `bulwark-mobile` where the webmail's admin OAuth setup registered it
|
||||||
|
* (webmail #1459), else registers its own public client (RFC 7591); 0.16
|
||||||
|
* takes `bulwark-webmail`. The run checks whichever the server has.
|
||||||
* - 1.0 rotates refresh tokens and revokes the whole grant when a superseded
|
* - 1.0 rotates refresh tokens and revokes the whole grant when a superseded
|
||||||
* one comes back after its successor was used.
|
* one comes back after its successor was used.
|
||||||
* - 1.0 has a revocation endpoint; 0.16 has none.
|
* - 1.0 has a revocation endpoint; 0.16 has none.
|
||||||
@@ -49,6 +51,7 @@ import {
|
|||||||
} from '../account-security';
|
} from '../account-security';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CLIENT_ID,
|
DEFAULT_CLIENT_ID,
|
||||||
|
MOBILE_CLIENT_ID,
|
||||||
REGISTERED_REDIRECT_URI,
|
REGISTERED_REDIRECT_URI,
|
||||||
exchangePasswordForTokens,
|
exchangePasswordForTokens,
|
||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
@@ -116,6 +119,14 @@ async function nextTotpCode(): Promise<string> {
|
|||||||
|
|
||||||
// Stalwart 1.0 is the line with RP-initiated logout; 0.16 has none.
|
// Stalwart 1.0 is the line with RP-initiated logout; 0.16 has none.
|
||||||
let isV1 = false;
|
let isV1 = false;
|
||||||
|
// A 1.0 where the webmail's admin OAuth setup registered `bulwark-mobile`.
|
||||||
|
let hasMobileClient = false;
|
||||||
|
|
||||||
|
/** The client a 1.0 sign-in must have used. */
|
||||||
|
function expectV1Client(clientId: string | null | undefined): void {
|
||||||
|
if (hasMobileClient) expect(clientId).toBe(MOBILE_CLIENT_ID);
|
||||||
|
else expect(clientId).toMatch(/^swc1\./);
|
||||||
|
}
|
||||||
|
|
||||||
function useSingleton(client: JMAPClient): void {
|
function useSingleton(client: JMAPClient): void {
|
||||||
(jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
(jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||||
@@ -140,6 +151,18 @@ live('live Stalwart sign-in', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const metadata = await discoverOAuthMetadata(SERVER);
|
const metadata = await discoverOAuthMetadata(SERVER);
|
||||||
isV1 = !!(metadata as { end_session_endpoint?: string } | null)?.end_session_endpoint;
|
isV1 = !!(metadata as { end_session_endpoint?: string } | null)?.end_session_endpoint;
|
||||||
|
if (isV1) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: MOBILE_CLIENT_ID,
|
||||||
|
redirect_uri: REGISTERED_REDIRECT_URI,
|
||||||
|
state: 's',
|
||||||
|
code_challenge: 'A'.repeat(43),
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string };
|
||||||
|
hasMobileClient = !check.error;
|
||||||
|
}
|
||||||
// Optional so the suite also runs against older sign-in code, to show
|
// Optional so the suite also runs against older sign-in code, to show
|
||||||
// what it does on a server.
|
// what it does on a server.
|
||||||
resetLoginClients?.();
|
resetLoginClients?.();
|
||||||
@@ -166,7 +189,7 @@ live('live Stalwart sign-in', () => {
|
|||||||
it('signs in with a client the server accepts, on a redirect it accepts', async () => {
|
it('signs in with a client the server accepts, on a redirect it accepts', async () => {
|
||||||
const client = await resolveLoginClient(SERVER);
|
const client = await resolveLoginClient(SERVER);
|
||||||
if (isV1) {
|
if (isV1) {
|
||||||
expect(client.clientId).toMatch(/^swc1\./);
|
expectV1Client(client.clientId);
|
||||||
expect(client.redirectUri).toBe(REGISTERED_REDIRECT_URI);
|
expect(client.redirectUri).toBe(REGISTERED_REDIRECT_URI);
|
||||||
// The browser sign-in's authorization request with this client and
|
// The browser sign-in's authorization request with this client and
|
||||||
// the scopes the app asks for passes 1.0's client check.
|
// the scopes the app asks for passes 1.0's client check.
|
||||||
@@ -182,7 +205,8 @@ live('live Stalwart sign-in', () => {
|
|||||||
});
|
});
|
||||||
const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string; scopes?: string[] };
|
const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string; scopes?: string[] };
|
||||||
expect(check.error).toBeUndefined();
|
expect(check.error).toBeUndefined();
|
||||||
expect(check.scopes?.length).toBeGreaterThan(1);
|
// A first-party client shows as one scope, "Full access to your account".
|
||||||
|
expect(check.scopes?.length).toBeGreaterThan(hasMobileClient ? 0 : 1);
|
||||||
} else {
|
} else {
|
||||||
expect(client).toEqual({ clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI });
|
expect(client).toEqual({ clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI });
|
||||||
}
|
}
|
||||||
@@ -208,7 +232,7 @@ live('live Stalwart sign-in', () => {
|
|||||||
expect(tokens.accessToken).toBeTruthy();
|
expect(tokens.accessToken).toBeTruthy();
|
||||||
expect(tokens.refreshToken).toBeTruthy();
|
expect(tokens.refreshToken).toBeTruthy();
|
||||||
expect(tokens.tokenEndpoint).toBe(`${SERVER}/auth/token`);
|
expect(tokens.tokenEndpoint).toBe(`${SERVER}/auth/token`);
|
||||||
if (isV1) expect(tokens.clientId).toMatch(/^swc1\./);
|
if (isV1) expectV1Client(tokens.clientId);
|
||||||
else expect(tokens.clientId).toBe(DEFAULT_CLIENT_ID);
|
else expect(tokens.clientId).toBe(DEFAULT_CLIENT_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,9 +261,9 @@ live('live Stalwart sign-in', () => {
|
|||||||
expect(await client.forceRefreshToken()).toBe(true);
|
expect(await client.forceRefreshToken()).toBe(true);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('account security: 1.0 refuses it to the registered client, 0.16 allows it', async () => {
|
it('account security: 1.0 refuses it to the registered client, allows it to bulwark-mobile; 0.16 allows it', async () => {
|
||||||
useSingleton(client);
|
useSingleton(client);
|
||||||
if (isV1) {
|
if (isV1 && !hasMobileClient) {
|
||||||
// The session hides the extension from this client, and the calls
|
// The session hides the extension from this client, and the calls
|
||||||
// themselves are refused.
|
// themselves are refused.
|
||||||
expect(isStalwartSupported()).toBe(false);
|
expect(isStalwartSupported()).toBe(false);
|
||||||
@@ -317,7 +341,7 @@ live('live Stalwart sign-in', () => {
|
|||||||
const authUrl = new URL(browser.mock.calls[0][0] as string);
|
const authUrl = new URL(browser.mock.calls[0][0] as string);
|
||||||
expect(`${authUrl.origin}${authUrl.pathname}`).toBe(metadata.authorization_endpoint);
|
expect(`${authUrl.origin}${authUrl.pathname}`).toBe(metadata.authorization_endpoint);
|
||||||
if (isV1) {
|
if (isV1) {
|
||||||
expect(authUrl.searchParams.get('client_id')).toMatch(/^swc1\./);
|
expectV1Client(authUrl.searchParams.get('client_id'));
|
||||||
expect(metadata.registration_endpoint).toBeTruthy();
|
expect(metadata.registration_endpoint).toBeTruthy();
|
||||||
} else {
|
} else {
|
||||||
// 0.16 on :8016 names its public address, not the one typed here, and
|
// 0.16 on :8016 names its public address, not the one typed here, and
|
||||||
@@ -355,7 +379,7 @@ live('live Stalwart sign-in', () => {
|
|||||||
tokenSource: string;
|
tokenSource: string;
|
||||||
};
|
};
|
||||||
expect(stored.tokenSource).toBe('totp');
|
expect(stored.tokenSource).toBe('totp');
|
||||||
if (isV1) expect(stored.clientId).toMatch(/^swc1\./);
|
if (isV1) expectV1Client(stored.clientId);
|
||||||
else expect(stored.clientId).toBe(DEFAULT_CLIENT_ID);
|
else expect(stored.clientId).toBe(DEFAULT_CLIENT_ID);
|
||||||
const res = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['role'] }, '0']]);
|
const res = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['role'] }, '0']]);
|
||||||
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
|
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
TotpLoginError,
|
TotpLoginError,
|
||||||
REGISTERED_REDIRECT_URI,
|
REGISTERED_REDIRECT_URI,
|
||||||
|
MOBILE_CLIENT_ID,
|
||||||
} from '../totp-login';
|
} from '../totp-login';
|
||||||
import { describeLoginError } from '../login-errors';
|
import { describeLoginError } from '../login-errors';
|
||||||
|
|
||||||
@@ -31,19 +32,28 @@ function json(body: unknown, status = 200): Response {
|
|||||||
interface Call { url: string; method: string; body?: string }
|
interface Call { url: string; method: string; body?: string }
|
||||||
|
|
||||||
// A fake Stalwart of either line. `v1` knows no client but the ones it
|
// A fake Stalwart of either line. `v1` knows no client but the ones it
|
||||||
// registered (ids `swc1.<n>`), takes `{query, consent}` and answers with a
|
// registered (ids `swc1.<n>`) and those in `admin` (registered by an admin
|
||||||
|
// for the given redirect URI), takes `{query, consent}` and answers with a
|
||||||
// location; 0.16 has no client check and answers `client_code`.
|
// location; 0.16 has no client check and answers `client_code`.
|
||||||
function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) => unknown) {
|
function fakeServer(
|
||||||
|
version: '0.16' | '1.0',
|
||||||
|
answer?: (query: URLSearchParams) => unknown,
|
||||||
|
admin: Record<string, string> = {},
|
||||||
|
) {
|
||||||
const calls: Call[] = [];
|
const calls: Call[] = [];
|
||||||
let registered = 0;
|
let registered = 0;
|
||||||
|
const knows = (clientId: string, redirectUri: string | null) =>
|
||||||
|
clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri);
|
||||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
||||||
calls.push(call);
|
calls.push(call);
|
||||||
const path = url.slice(BASE.length).split('?')[0];
|
const path = url.slice(BASE.length).split('?')[0];
|
||||||
if (path === '/api/auth/client') {
|
if (path === '/api/auth/client') {
|
||||||
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
||||||
const clientId = new URL(url).searchParams.get('client_id') ?? '';
|
const params = new URL(url).searchParams;
|
||||||
return json(clientId.startsWith('swc1.') ? { name: 'Bulwark Mobile' } : { error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
return json(knows(params.get('client_id') ?? '', params.get('redirect_uri'))
|
||||||
|
? { name: 'Bulwark Mobile' }
|
||||||
|
: { error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
||||||
}
|
}
|
||||||
if (path === '/auth/register') {
|
if (path === '/auth/register') {
|
||||||
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
|
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
|
||||||
@@ -56,7 +66,7 @@ function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) =
|
|||||||
if (typeof body.query !== 'string') return json({ title: 'Invalid parameters', detail: 'JSON deserialization failed' }, 400);
|
if (typeof body.query !== 'string') return json({ title: 'Invalid parameters', detail: 'JSON deserialization failed' }, 400);
|
||||||
const query = new URLSearchParams(body.query);
|
const query = new URLSearchParams(body.query);
|
||||||
if (answer) return json(answer(query));
|
if (answer) return json(answer(query));
|
||||||
if (!query.get('client_id')?.startsWith('swc1.')) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
|
if (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
|
||||||
return json({
|
return json({
|
||||||
type: 'authenticated',
|
type: 'authenticated',
|
||||||
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
|
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
|
||||||
@@ -111,6 +121,21 @@ describe('exchangePasswordForTokens', () => {
|
|||||||
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('signs in on Stalwart 1.0 through the bulwark-mobile client the webmail setup registered (webmail #1459)', async () => {
|
||||||
|
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe(MOBILE_CLIENT_ID);
|
||||||
|
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
|
||||||
|
const query = new URLSearchParams(JSON.parse(authCalls(calls)[0].body!).query);
|
||||||
|
expect(query.get('client_id')).toBe(MOBILE_CLIENT_ID);
|
||||||
|
expect(query.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
||||||
|
expect(tokenCall(calls).get('client_id')).toBe(MOBILE_CLIENT_ID);
|
||||||
|
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
||||||
|
// A public client: there is no secret to send.
|
||||||
|
expect(tokenCall(calls).has('client_secret')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the registered client per server for the next sign-in', async () => {
|
it('keeps the registered client per server for the next sign-in', async () => {
|
||||||
fakeServer('1.0');
|
fakeServer('1.0');
|
||||||
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
@@ -289,6 +314,27 @@ describe('resolveLoginClient', () => {
|
|||||||
const client = await resolveLoginClient(BASE, { registrationEndpoint: 'https://public.example.com/auth/register' });
|
const client = await resolveLoginClient(BASE, { registrationEndpoint: 'https://public.example.com/auth/register' });
|
||||||
|
|
||||||
expect(client.clientId).toBe('swc1.pub');
|
expect(client.clientId).toBe('swc1.pub');
|
||||||
expect(urls).toEqual([`${BASE}/api/auth/client`, 'https://public.example.com/auth/register']);
|
expect(urls).toEqual([`${BASE}/api/auth/client`, `${BASE}/api/auth/client`, 'https://public.example.com/auth/register']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for bulwark-mobile first, then the webmail client, before registering one', async () => {
|
||||||
|
const calls = fakeServer('1.0');
|
||||||
|
await resolveLoginClient(BASE);
|
||||||
|
|
||||||
|
const checked = calls
|
||||||
|
.filter((c) => c.url.startsWith(`${BASE}/api/auth/client`))
|
||||||
|
.map((c) => new URL(c.url).searchParams);
|
||||||
|
expect(checked.map((p) => [p.get('client_id'), p.get('redirect_uri')])).toEqual([
|
||||||
|
[MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI],
|
||||||
|
['bulwark-webmail', 'bulwarkmobile://auth/callback'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers bulwark-mobile for the next sign-in', async () => {
|
||||||
|
fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
|
||||||
|
await resolveLoginClient(BASE);
|
||||||
|
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
|
||||||
|
expect((await resolveLoginClient(BASE)).clientId).toBe(MOBILE_CLIENT_ID);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-37
@@ -36,14 +36,23 @@ export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect URI of the client the app registers at a Stalwart 1.0 that knows
|
* Redirect URI of the client the app registers at a Stalwart 1.0 that knows
|
||||||
* no client for it. 1.0 lets a public client register only a loopback URI or
|
* no client for it, and of `bulwark-mobile`. 1.0 lets a public client
|
||||||
* a private-use scheme with a dot in it (RFC 8252), which `bulwarkmobile://`
|
* register only a loopback URI or a private-use scheme with a dot in it
|
||||||
* is not; this one is the iOS bundle id. The password login never visits it
|
* (RFC 8252), which `bulwarkmobile://` is not; this one is the App Store and
|
||||||
* (/api/auth hands the location back); the browser sign-in comes back on it,
|
* Play id. The password login never visits it (/api/auth hands the location
|
||||||
* so the app registers the scheme too.
|
* back); the browser sign-in comes back on it, so the app registers the
|
||||||
|
* scheme too.
|
||||||
*/
|
*/
|
||||||
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's own client, which the webmail's admin OAuth setup registers on
|
||||||
|
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
|
||||||
|
* the account's Stalwart settings, which those of a client the app
|
||||||
|
* registers itself don't.
|
||||||
|
*/
|
||||||
|
export const MOBILE_CLIENT_ID = 'bulwark-mobile';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The data scopes Stalwart 1.0 grants a client that isn't first-party. A
|
* The data scopes Stalwart 1.0 grants a client that isn't first-party. A
|
||||||
* token reaches only what these name (no scope: nothing but the session);
|
* token reaches only what these name (no scope: nothing but the session);
|
||||||
@@ -189,27 +198,20 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OAuth client to sign in to `serverUrl` as. Stalwart 1.0's client check
|
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't
|
||||||
* (GET /api/auth/client, which 0.16 doesn't have) says whether it knows the
|
* have): does the server take `clientId` on `redirectUri`? `busy` for a
|
||||||
* webmail's client id with the app's redirect URI; when it doesn't, the app
|
* server having a bad moment (5xx, 429), `none` where there is no such
|
||||||
* registers its own public client, at `registrationEndpoint` when the OAuth
|
* check; an unreachable server throws `NetworkError`.
|
||||||
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as
|
|
||||||
* before. A server having a bad moment (5xx, 429) gets the default without
|
|
||||||
* it being remembered; an unreachable one throws `NetworkError`, as the
|
|
||||||
* default would only end in the server refusing a client it doesn't know.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveLoginClient(
|
async function checkClient(
|
||||||
serverUrl: string,
|
base: string,
|
||||||
opts?: { registrationEndpoint?: string },
|
clientId: string,
|
||||||
): Promise<LoginClient> {
|
redirectUri: string,
|
||||||
const base = trimBase(serverUrl);
|
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
||||||
const cached = loginClients.get(base);
|
|
||||||
if (cached) return cached;
|
|
||||||
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: DEFAULT_CLIENT_ID,
|
client_id: clientId,
|
||||||
redirect_uri: HANDOFF_REDIRECT_URI,
|
redirect_uri: redirectUri,
|
||||||
state: 'probe',
|
state: 'probe',
|
||||||
code_challenge: 'A'.repeat(43),
|
code_challenge: 'A'.repeat(43),
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
@@ -228,24 +230,50 @@ export async function resolveLoginClient(
|
|||||||
} finally {
|
} finally {
|
||||||
t.done();
|
t.done();
|
||||||
}
|
}
|
||||||
if (!response || typeof response.status !== 'number') return fallback;
|
if (!response || typeof response.status !== 'number') return 'busy';
|
||||||
assertSameServer(url, response);
|
assertSameServer(url, response);
|
||||||
if (response.status >= 500 || response.status === 429 || response.status === 408) return fallback;
|
if (response.status >= 500 || response.status === 429 || response.status === 408) return 'busy';
|
||||||
// 0.16 and servers that aren't Stalwart: no such endpoint.
|
// 0.16 and servers that aren't Stalwart: no such endpoint.
|
||||||
if (!response.ok) {
|
if (!response.ok) return 'none';
|
||||||
rememberClient(base, fallback);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||||
if (!body || typeof body !== 'object') {
|
if (!body || typeof body !== 'object') return 'none';
|
||||||
rememberClient(base, fallback);
|
// `invalid_client` (unknown id) or a redirect URI the client doesn't list.
|
||||||
return fallback;
|
return typeof body.error === 'string' && body.error ? 'unknown' : 'known';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OAuth client to sign in to `serverUrl` as. On Stalwart 1.0 that is
|
||||||
|
* `bulwark-mobile` where the admin's OAuth setup registered it, else the
|
||||||
|
* webmail's client if it takes the app's redirect URI, else a public client
|
||||||
|
* the app registers itself, at `registrationEndpoint` when the OAuth
|
||||||
|
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as
|
||||||
|
* before. A server having a bad moment (5xx, 429) gets the default without
|
||||||
|
* it being remembered; an unreachable one throws `NetworkError`, as the
|
||||||
|
* default would only end in the server refusing a client it doesn't know.
|
||||||
|
*/
|
||||||
|
export async function resolveLoginClient(
|
||||||
|
serverUrl: string,
|
||||||
|
opts?: { registrationEndpoint?: string },
|
||||||
|
): Promise<LoginClient> {
|
||||||
|
const base = trimBase(serverUrl);
|
||||||
|
const cached = loginClients.get(base);
|
||||||
|
if (cached) return cached;
|
||||||
|
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
||||||
|
|
||||||
|
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI);
|
||||||
|
if (own === 'busy') return fallback;
|
||||||
|
if (own === 'known') {
|
||||||
|
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
|
||||||
|
rememberClient(base, client);
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
// `invalid_client` (unknown id) or a redirect URI the admin's client
|
if (own === 'unknown') {
|
||||||
// doesn't list: either way the webmail's client isn't the app's to use.
|
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI);
|
||||||
if (typeof body.error === 'string' && body.error) {
|
if (shared === 'busy') return fallback;
|
||||||
const endpoint = opts?.registrationEndpoint;
|
if (shared === 'unknown') {
|
||||||
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
|
const endpoint = opts?.registrationEndpoint;
|
||||||
|
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rememberClient(base, fallback);
|
rememberClient(base, fallback);
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user