mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +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.
|
||||
*
|
||||
* 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
|
||||
* client (RFC 7591); 0.16 takes `bulwark-webmail`.
|
||||
* - 1.0 knows only registered clients, so the app signs in as
|
||||
* `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
|
||||
* one comes back after its successor was used.
|
||||
* - 1.0 has a revocation endpoint; 0.16 has none.
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
} from '../account-security';
|
||||
import {
|
||||
DEFAULT_CLIENT_ID,
|
||||
MOBILE_CLIENT_ID,
|
||||
REGISTERED_REDIRECT_URI,
|
||||
exchangePasswordForTokens,
|
||||
resolveLoginClient,
|
||||
@@ -116,6 +119,14 @@ async function nextTotpCode(): Promise<string> {
|
||||
|
||||
// Stalwart 1.0 is the line with RP-initiated logout; 0.16 has none.
|
||||
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 {
|
||||
(jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
@@ -140,6 +151,18 @@ live('live Stalwart sign-in', () => {
|
||||
beforeAll(async () => {
|
||||
const metadata = await discoverOAuthMetadata(SERVER);
|
||||
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
|
||||
// what it does on a server.
|
||||
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 () => {
|
||||
const client = await resolveLoginClient(SERVER);
|
||||
if (isV1) {
|
||||
expect(client.clientId).toMatch(/^swc1\./);
|
||||
expectV1Client(client.clientId);
|
||||
expect(client.redirectUri).toBe(REGISTERED_REDIRECT_URI);
|
||||
// The browser sign-in's authorization request with this client and
|
||||
// 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[] };
|
||||
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 {
|
||||
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.refreshToken).toBeTruthy();
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -237,9 +261,9 @@ live('live Stalwart sign-in', () => {
|
||||
expect(await client.forceRefreshToken()).toBe(true);
|
||||
}, 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);
|
||||
if (isV1) {
|
||||
if (isV1 && !hasMobileClient) {
|
||||
// The session hides the extension from this client, and the calls
|
||||
// themselves are refused.
|
||||
expect(isStalwartSupported()).toBe(false);
|
||||
@@ -317,7 +341,7 @@ live('live Stalwart sign-in', () => {
|
||||
const authUrl = new URL(browser.mock.calls[0][0] as string);
|
||||
expect(`${authUrl.origin}${authUrl.pathname}`).toBe(metadata.authorization_endpoint);
|
||||
if (isV1) {
|
||||
expect(authUrl.searchParams.get('client_id')).toMatch(/^swc1\./);
|
||||
expectV1Client(authUrl.searchParams.get('client_id'));
|
||||
expect(metadata.registration_endpoint).toBeTruthy();
|
||||
} else {
|
||||
// 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;
|
||||
};
|
||||
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);
|
||||
const res = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['role'] }, '0']]);
|
||||
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveLoginClient,
|
||||
TotpLoginError,
|
||||
REGISTERED_REDIRECT_URI,
|
||||
MOBILE_CLIENT_ID,
|
||||
} from '../totp-login';
|
||||
import { describeLoginError } from '../login-errors';
|
||||
|
||||
@@ -31,19 +32,28 @@ function json(body: unknown, status = 200): Response {
|
||||
interface Call { url: string; method: string; body?: string }
|
||||
|
||||
// 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`.
|
||||
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[] = [];
|
||||
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) => {
|
||||
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
||||
calls.push(call);
|
||||
const path = url.slice(BASE.length).split('?')[0];
|
||||
if (path === '/api/auth/client') {
|
||||
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
||||
const clientId = new URL(url).searchParams.get('client_id') ?? '';
|
||||
return json(clientId.startsWith('swc1.') ? { name: 'Bulwark Mobile' } : { error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
||||
const params = new URL(url).searchParams;
|
||||
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 (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);
|
||||
const query = new URLSearchParams(body.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({
|
||||
type: 'authenticated',
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
fakeServer('1.0');
|
||||
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' });
|
||||
|
||||
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
|
||||
* no client for it. 1.0 lets a public client register only a loopback URI or
|
||||
* a private-use scheme with a dot in it (RFC 8252), which `bulwarkmobile://`
|
||||
* is not; this one is the iOS bundle id. The password login never visits it
|
||||
* (/api/auth hands the location back); the browser sign-in comes back on it,
|
||||
* so the app registers the scheme too.
|
||||
* no client for it, and of `bulwark-mobile`. 1.0 lets a public client
|
||||
* register only a loopback URI or a private-use scheme with a dot in it
|
||||
* (RFC 8252), which `bulwarkmobile://` is not; this one is the App Store and
|
||||
* Play id. The password login never visits it (/api/auth hands the location
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* (GET /api/auth/client, which 0.16 doesn't have) says whether it knows the
|
||||
* webmail's client id with the app's redirect URI; when it doesn't, the app
|
||||
* registers its own public client, 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.
|
||||
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't
|
||||
* have): does the server take `clientId` on `redirectUri`? `busy` for a
|
||||
* server having a bad moment (5xx, 429), `none` where there is no such
|
||||
* check; an unreachable server throws `NetworkError`.
|
||||
*/
|
||||
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 };
|
||||
async function checkClient(
|
||||
base: string,
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: DEFAULT_CLIENT_ID,
|
||||
redirect_uri: HANDOFF_REDIRECT_URI,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
state: 'probe',
|
||||
code_challenge: 'A'.repeat(43),
|
||||
code_challenge_method: 'S256',
|
||||
@@ -228,24 +230,50 @@ export async function resolveLoginClient(
|
||||
} finally {
|
||||
t.done();
|
||||
}
|
||||
if (!response || typeof response.status !== 'number') return fallback;
|
||||
if (!response || typeof response.status !== 'number') return 'busy';
|
||||
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.
|
||||
if (!response.ok) {
|
||||
rememberClient(base, fallback);
|
||||
return fallback;
|
||||
}
|
||||
if (!response.ok) return 'none';
|
||||
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||
if (!body || typeof body !== 'object') {
|
||||
rememberClient(base, fallback);
|
||||
return fallback;
|
||||
if (!body || typeof body !== 'object') return 'none';
|
||||
// `invalid_client` (unknown id) or a redirect URI the client doesn't list.
|
||||
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
|
||||
// doesn't list: either way the webmail's client isn't the app's to use.
|
||||
if (typeof body.error === 'string' && body.error) {
|
||||
const endpoint = opts?.registrationEndpoint;
|
||||
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
|
||||
if (own === 'unknown') {
|
||||
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI);
|
||||
if (shared === 'busy') return fallback;
|
||||
if (shared === 'unknown') {
|
||||
const endpoint = opts?.registrationEndpoint;
|
||||
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
|
||||
}
|
||||
}
|
||||
rememberClient(base, fallback);
|
||||
return fallback;
|
||||
|
||||
Reference in New Issue
Block a user