Compare commits

...
5 Commits
11 changed files with 207 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.88 0.1.90
+7 -6
View File
@@ -57,12 +57,13 @@ module.exports = {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: 'org.bulwarkmail.mobile', bundleIdentifier: 'org.bulwarkmail.mobile',
buildNumber: IOS_BUILD_NUMBER, buildNumber: IOS_BUILD_NUMBER,
config: { // No usesNonExemptEncryption here. PGP and S/MIME (#58) are non-exempt
// PGP and S/MIME (#58) add end-to-end encryption beyond HTTPS/TLS, // encryption, but only standard algorithms from open-source libraries
// so the app declares non-exempt encryption; App Store Connect asks // and the app is not on the French store, so App Store Connect keeps no
// for the export compliance documentation on upload. // declaration and has no ITSEncryptionExportComplianceCode to give. An
usesNonExemptEncryption: true, // Info.plist saying YES without that code fails upload validation
}, // (90592), so the key stays out and scripts/testflight-public.mjs
// answers the question for each build through the API.
}, },
android: { android: {
adaptiveIcon: { adaptiveIcon: {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-mobile", "name": "bulwark-mobile",
"version": "0.1.88", "version": "0.1.90",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-mobile", "name": "bulwark-mobile",
"version": "0.1.88", "version": "0.1.90",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-mobile", "name": "bulwark-mobile",
"version": "0.1.88", "version": "0.1.90",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
+14 -2
View File
@@ -99,7 +99,7 @@ if (!group) {
// ── Wait for the build ───────────────────────────────────────── // ── Wait for the build ─────────────────────────────────────────
const query = const query =
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` + `/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`; `&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState,usesNonExemptEncryption`;
const started = Date.now(); const started = Date.now();
let build; let build;
for (;;) { for (;;) {
@@ -121,6 +121,18 @@ for (;;) {
} }
console.log(`Build ${version} (${buildNumber}) is processed.`); console.log(`Build ${version} (${buildNumber}) is processed.`);
// ── Export compliance ──────────────────────────────────────────
// The app uses non-exempt encryption (PGP and S/MIME, #58) with standard
// algorithms only and is not on the French store: App Store Connect needs no
// documentation, so Info.plist has no key and the answer is given here.
if (build.attributes.usesNonExemptEncryption == null) {
const answer = await api('PATCH', `/v1/builds/${build.id}`, {
data: { type: 'builds', id: build.id, attributes: { usesNonExemptEncryption: true } },
});
if (!answer.ok) fail('Answering export compliance', answer);
console.log('Export compliance answered: standard encryption, no documentation needed.');
}
// ── Add it to the group ──────────────────────────────────────── // ── Add it to the group ────────────────────────────────────────
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, { const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
data: [{ type: 'builds', id: build.id }], data: [{ type: 'builds', id: build.id }],
@@ -150,7 +162,7 @@ const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
if (!detail.ok) fail('Reading the build state', detail); if (!detail.ok) fail('Reading the build state', detail);
const external = detail.json.data.attributes.externalBuildState; const external = detail.json.data.attributes.externalBuildState;
if (external === 'MISSING_EXPORT_COMPLIANCE') { if (external === 'MISSING_EXPORT_COMPLIANCE') {
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.'); console.error('The build still has no export compliance answer; answer it in App Store Connect > TestFlight.');
process.exit(1); process.exit(1);
} }
if (external === 'READY_FOR_BETA_SUBMISSION') { if (external === 'READY_FOR_BETA_SUBMISSION') {
@@ -15,6 +15,7 @@ vi.mock('../booking-calendar-actions', async () => {
}); });
import type { Email } from '../../../../api/types'; import type { Email } from '../../../../api/types';
import { usePolicyStore } from '../../../../stores/policy-store';
import { useSettingsStore } from '../../../../stores/settings-store'; import { useSettingsStore } from '../../../../stores/settings-store';
import { StructuredMailBanner } from '../StructuredMailBanner'; import { StructuredMailBanner } from '../StructuredMailBanner';
import { byTestId, render, textOf } from './render-native'; import { byTestId, render, textOf } from './render-native';
@@ -37,7 +38,11 @@ function mail(ar: string | null, subject = 'Your order'): Email {
} }
const initialSettings = useSettingsStore.getState(); const initialSettings = useSettingsStore.getState();
afterEach(() => useSettingsStore.setState(initialSettings, true)); const initialPolicy = usePolicyStore.getState();
afterEach(() => {
useSettingsStore.setState(initialSettings, true);
usePolicyStore.setState(initialPolicy, true);
});
describe('StructuredMailBanner', () => { describe('StructuredMailBanner', () => {
it('shows the order card of a verified mail', () => { it('shows the order card of a verified mail', () => {
@@ -67,6 +72,8 @@ describe('StructuredMailBanner', () => {
}); });
it('loads product images only when remote content would load', () => { it('loads product images only when remote content would load', () => {
// The webmail's config is known and allows third-party traffic.
usePolicyStore.setState({ configPending: false, links: {} });
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] }); useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] });
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0); expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] }); useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] });
@@ -74,4 +81,12 @@ describe('StructuredMailBanner', () => {
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] }); useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1); expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1);
}); });
it('loads no product images while the webmail config is unknown or forbids third-party traffic', () => {
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
usePolicyStore.setState({ configPending: true, links: {} });
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
usePolicyStore.setState({ configPending: false, links: { noThirdPartyTraffic: true } });
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
});
}); });
+15 -6
View File
@@ -2,13 +2,22 @@ import { describe, expect, it } from 'vitest';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
// With PGP and S/MIME in the app (#58) the encryption is no longer only // PGP and S/MIME (#58) are non-exempt encryption with standard algorithms, and
// HTTPS/TLS, so the App Store export compliance answer must say so. // the app is not on the French store, so App Store Connect has no declaration
// and no compliance code. Info.plist must not claim YES (upload validation
// rejects it without the code, 90592) nor NO (that would be untrue); the
// TestFlight script answers per build instead.
const root = path.resolve(__dirname, '../../..');
describe('export compliance', () => { describe('export compliance', () => {
it('declares non-exempt encryption for iOS', () => { it('leaves the encryption key out of the iOS config', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../../app.config.js'), 'utf8'); const src = fs.readFileSync(path.join(root, 'app.config.js'), 'utf8');
expect(src).toMatch(/usesNonExemptEncryption: true/); expect(src).not.toMatch(/usesNonExemptEncryption\s*:/);
expect(src).not.toMatch(/usesNonExemptEncryption: false/); });
it('answers non-exempt encryption for each TestFlight build', () => {
const src = fs.readFileSync(path.join(root, 'scripts/testflight-public.mjs'), 'utf8');
expect(src).toMatch(/attributes: \{ usesNonExemptEncryption: true \}/);
}); });
}); });
+46
View File
@@ -151,6 +151,52 @@ describe('loginWithPkce on Stalwart', () => {
expect(scopes).not.toContain('profile'); expect(scopes).not.toContain('profile');
}); });
// 1.0.3 dropped first-party clients: the bulwark-mobile client the webmail
// setup registered now allows only openid and offline_access, and refuses
// the whole request with invalid_scope.
const stalwart103: OAuthMetadata = {
...stalwart,
scopes_supported: [
...stalwart.scopes_supported!,
'urn:stalwart:oauth:scope:security',
'urn:stalwart:oauth:scope:settings',
'urn:stalwart:oauth:scope:full',
],
};
it('registers its own client on 1.0.3 when bulwark-mobile does not allow the scopes it asks for', async () => {
const checked: URLSearchParams[] = [];
fetchMock.mockImplementation(async (url: string) => {
if (url.includes('/api/auth/client')) {
const q = new URL(url).searchParams;
checked.push(q);
if (q.get('client_id') !== 'bulwark-mobile') return answer({ error: 'invalid_client' });
const extra = (q.get('scope') ?? '').split(' ').filter((s) => s && s !== 'openid' && s !== 'offline_access');
return extra.length
? answer({ error: 'invalid_request', redirect: true, location: `${REGISTERED_REDIRECT_URI}?error=invalid_scope` })
: answer({ name: 'Bulwark Mobile (auto-configured)', scopes: ['Full access to your account'] });
}
if (url.endsWith('/auth/register')) return answer({ client_id: 'swc1.own' }, 201);
return answer({}, 404);
});
const { params } = await openedSession(stalwart103);
expect(params.get('client_id')).toBe('swc1.own');
// The check asked with the scopes the browser then asks for.
expect(checked[0].get('scope')).toBe(params.get('scope'));
});
it("asks 1.0.3 for the account's settings, but not for full access", async () => {
fetchMock.mockImplementation(async () => answer({ status: 404 }, 404));
const { params } = await openedSession(stalwart103);
const scopes = params.get('scope')!.split(' ');
expect(scopes).toEqual(expect.arrayContaining(['urn:stalwart:oauth:scope:security', 'urn:stalwart:oauth:scope:settings']));
expect(scopes).not.toContain('urn:stalwart:oauth:scope:full');
// A server that doesn't list them isn't asked for them.
expect(pkceScopes(stalwart)).not.toContain('urn:stalwart:');
});
it('asks another identity provider for the usual OpenID scopes', () => { it('asks another identity provider for the usual OpenID scopes', () => {
expect(pkceScopes({ ...metadata, scopes_supported: ['openid', 'email', 'profile', 'offline_access', 'groups'] })) expect(pkceScopes({ ...metadata, scopes_supported: ['openid', 'email', 'profile', 'offline_access', 'groups'] }))
.toBe('openid email profile offline_access'); .toBe('openid email profile offline_access');
+42 -3
View File
@@ -34,16 +34,26 @@ 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>`) and those in `admin` (registered by an admin // registered (ids `swc1.<n>`) and those in `admin` (registered by an admin
// for the given redirect URI), takes `{query, consent}` and answers with a // 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`. An admin
// client listed in `adminScopes` allows only those scopes besides openid and
// offline_access (1.0.3, which has no first-party clients), and a request
// naming another is refused with invalid_scope; any other client allows all.
function fakeServer( function fakeServer(
version: '0.16' | '1.0', version: '0.16' | '1.0',
answer?: (query: URLSearchParams) => unknown, answer?: (query: URLSearchParams) => unknown,
admin: Record<string, string> = {}, admin: Record<string, string> = {},
adminScopes: Record<string, string[]> = {},
) { ) {
const calls: Call[] = []; const calls: Call[] = [];
let registered = 0; let registered = 0;
const knows = (clientId: string, redirectUri: string | null) => const knows = (clientId: string, redirectUri: string | null) =>
clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri); clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri);
const allows = (clientId: string, scope: string | null) => {
const allowed = adminScopes[clientId];
if (!allowed) return true;
return (scope ?? '').split(' ').every((s) => !s || s === 'openid' || s === 'offline_access' || allowed.includes(s));
};
const scopeRefused = (redirectUri: string | null) => `${redirectUri}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed+for+this+client.`;
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);
@@ -51,9 +61,13 @@ function fakeServer(
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 params = new URL(url).searchParams; const params = new URL(url).searchParams;
return json(knows(params.get('client_id') ?? '', params.get('redirect_uri')) const clientId = params.get('client_id') ?? '';
if (!knows(clientId, params.get('redirect_uri'))) {
return json({ error: 'invalid_client', description: 'Unknown client id', redirect: false });
}
return json(allows(clientId, params.get('scope'))
? { name: 'Bulwark Mobile' } ? { name: 'Bulwark Mobile' }
: { error: 'invalid_client', description: 'Unknown client id', redirect: false }); : { error: 'invalid_request', redirect: true, location: scopeRefused(params.get('redirect_uri')) });
} }
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);
@@ -67,6 +81,7 @@ function fakeServer(
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 (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) 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' });
if (!allows(query.get('client_id') ?? '', query.get('scope'))) return json({ type: 'redirect', location: scopeRefused(query.get('redirect_uri')) });
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`,
@@ -153,6 +168,30 @@ describe('exchangePasswordForTokens', () => {
expect(tokenCall(calls).has('client_secret')).toBe(false); expect(tokenCall(calls).has('client_secret')).toBe(false);
}); });
it('registers its own client on Stalwart 1.0.3, where bulwark-mobile lost its first-party access', async () => {
// 1.0.3 has no first-party clients: bulwark-mobile now allows only openid
// and offline_access and refuses the app's request with invalid_scope.
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }, { 'bulwark-mobile': [] });
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
expect(tokens.clientId).toBe('swc1.1');
expect(authCalls(calls)).toHaveLength(1);
const register = JSON.parse(calls.find((c) => c.url.endsWith('/auth/register'))!.body!);
expect(register.scope).toContain('urn:stalwart:oauth:scope:security');
expect(register.scope).not.toContain('urn:stalwart:oauth:scope:full');
});
it('asks again when a remembered bulwark-mobile no longer allows the scopes, without a second password check failing', async () => {
fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
// The server is upgraded to 1.0.3 while the app keeps running.
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }, { 'bulwark-mobile': [] });
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '654321');
expect(tokens.clientId).toBe('swc1.1');
expect(tokenCall(calls).get('client_id')).toBe('swc1.1');
});
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');
+15 -4
View File
@@ -14,9 +14,11 @@ import * as WebBrowser from 'expo-web-browser';
import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect, secureFetch } from './client-cert'; import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect, secureFetch } from './client-cert';
import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth'; import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth';
import { import {
forgetLoginClient,
generateCodeChallenge, generateCodeChallenge,
generateCodeVerifier, generateCodeVerifier,
resolveLoginClient, resolveLoginClient,
STALWART_ACCOUNT_SCOPES,
DEFAULT_CLIENT_ID, DEFAULT_CLIENT_ID,
MOBILE_CLIENT_ID, MOBILE_CLIENT_ID,
REGISTERED_LOGOUT_REDIRECT_URI, REGISTERED_LOGOUT_REDIRECT_URI,
@@ -142,7 +144,8 @@ const DATA_SCOPE_PREFIX = 'urn:ietf:params:oauth:scope:';
* advertises OAuth data scopes is Stalwart: 1.0 gives a client it doesn't * advertises OAuth data scopes is Stalwart: 1.0 gives a client it doesn't
* treat as first-party only what these name, and refuses a scope it doesn't * treat as first-party only what these name, and refuses a scope it doesn't
* list (`email`, `profile`) with `invalid_scope`. For such a provider the * list (`email`, `profile`) with `invalid_scope`. For such a provider the
* scopes it doesn't list are dropped and every data scope it lists is added; * scopes it doesn't list are dropped and every data scope it lists is added,
* with the account-settings scopes 1.0.3 lists (STALWART_ACCOUNT_SCOPES);
* any other provider gets the usual OIDC set. * any other provider gets the usual OIDC set.
*/ */
export function pkceScopes(metadata: OAuthMetadata): string { export function pkceScopes(metadata: OAuthMetadata): string {
@@ -151,7 +154,8 @@ export function pkceScopes(metadata: OAuthMetadata): string {
if (!supported?.length) return wanted.join(' '); if (!supported?.length) return wanted.join(' ');
const kept = wanted.filter((s) => supported.includes(s)); const kept = wanted.filter((s) => supported.includes(s));
const data = supported.filter((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX)); const data = supported.filter((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
if (data.length) return [...new Set([...kept, ...data])].join(' '); const account = STALWART_ACCOUNT_SCOPES.filter((s) => supported.includes(s));
if (data.length) return [...new Set([...kept, ...data, ...account])].join(' ');
return kept.join(' ') || supported.join(' '); return kept.join(' ') || supported.join(' ');
} }
@@ -170,7 +174,10 @@ async function pkceClient(serverUrl: string, metadata: OAuthMetadata): Promise<{
const stalwart = !!metadata.scopes_supported?.some((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX)); const stalwart = !!metadata.scopes_supported?.some((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
if (onServer || stalwart) { if (onServer || stalwart) {
try { try {
return await resolveLoginClient(serverUrl, { registrationEndpoint: registrationEndpoint(metadata) }); return await resolveLoginClient(serverUrl, {
registrationEndpoint: registrationEndpoint(metadata),
scope: pkceScopes(metadata),
});
} catch (err) { } catch (err) {
// Unreachable: say so, rather than let the browser show the server // Unreachable: say so, rather than let the browser show the server
// refusing a client it doesn't know. // refusing a client it doesn't know.
@@ -232,7 +239,11 @@ export async function loginWithPkce(
const cb = parseCallbackParams(result.url); const cb = parseCallbackParams(result.url);
const err = cb.get('error'); const err = cb.get('error');
if (err) throw new HandoffError(cb.get('error_description') || err); if (err) {
// A remembered client the server no longer takes: the next try asks again.
if (!opts?.clientId && (err === 'invalid_client' || err === 'invalid_scope')) forgetLoginClient(serverUrl);
throw new HandoffError(cb.get('error_description') || err);
}
if (cb.get('state') !== state) throw new HandoffError('State mismatch'); if (cb.get('state') !== state) throw new HandoffError('State mismatch');
const code = cb.get('code'); const code = cb.get('code');
if (!code) throw new HandoffError('Sign-in response missing authorization code'); if (!code) throw new HandoffError('Sign-in response missing authorization code');
+48 -16
View File
@@ -76,7 +76,20 @@ export const STALWART_DATA_SCOPES = [
'urn:ietf:params:oauth:scope:files', 'urn:ietf:params:oauth:scope:files',
]; ];
export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES].join(' '); /**
* Stalwart 1.0.3's scopes for the account's own settings (password, 2FA, app
* passwords, encryption keys), which until then only a first-party client
* reached. 1.0.3 dropped first-party clients, so `bulwark-mobile` too gets
* only what its scopes name. `urn:stalwart:oauth:scope:full` isn't asked for:
* only a client a superuser allowed it may, and a request naming a scope the
* client lacks is refused as a whole. Earlier servers skip these names.
*/
export const STALWART_ACCOUNT_SCOPES = [
'urn:stalwart:oauth:scope:security',
'urn:stalwart:oauth:scope:settings',
];
export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES, ...STALWART_ACCOUNT_SCOPES].join(' ');
export class LoginEndpointMissingError extends Error { export class LoginEndpointMissingError extends Error {
constructor() { constructor() {
@@ -137,6 +150,11 @@ export function resetLoginClients(): void {
loginClients.clear(); loginClients.clear();
} }
/** Drop the client remembered for `serverUrl`, which the server just refused. */
export function forgetLoginClient(serverUrl: string): void {
loginClients.delete(trimBase(serverUrl));
}
function rememberClient(base: string, client: LoginClient): void { function rememberClient(base: string, client: LoginClient): void {
if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) { if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) {
const oldest = loginClients.keys().next().value; const oldest = loginClients.keys().next().value;
@@ -209,14 +227,18 @@ async function registerPublicClient(
/** /**
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't * 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 * have): does the server take `clientId` on `redirectUri` asking for
* server having a bad moment (5xx, 429), `none` where there is no such * `scope`? A client registered without the scopes the app asks for
* check; an unreachable server throws `NetworkError`. * (`bulwark-mobile` on 1.0.3, which dropped first-party clients) counts as
* unknown, since the server refuses the whole request with `invalid_scope`.
* `busy` for a server having a bad moment (5xx, 429), `none` where there is
* no such check; an unreachable server throws `NetworkError`.
*/ */
async function checkClient( async function checkClient(
base: string, base: string,
clientId: string, clientId: string,
redirectUri: string, redirectUri: string,
scope: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<'known' | 'unknown' | 'none' | 'busy'> { ): Promise<'known' | 'unknown' | 'none' | 'busy'> {
const query = new URLSearchParams({ const query = new URLSearchParams({
@@ -224,6 +246,7 @@ async function checkClient(
client_id: clientId, client_id: clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
state: 'probe', state: 'probe',
scope,
code_challenge: 'A'.repeat(43), code_challenge: 'A'.repeat(43),
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
@@ -249,7 +272,8 @@ async function checkClient(
if (!response.ok) return 'none'; if (!response.ok) return 'none';
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') return 'none'; if (!body || typeof body !== 'object') return 'none';
// `invalid_client` (unknown id) or a redirect URI the client doesn't list. // `invalid_client` (unknown id), a redirect URI the client doesn't list,
// or (as a `redirect` carrying `invalid_scope`) a scope it doesn't allow.
return typeof body.error === 'string' && body.error ? 'unknown' : 'known'; return typeof body.error === 'string' && body.error ? 'unknown' : 'known';
} }
@@ -258,22 +282,25 @@ async function checkClient(
* `bulwark-mobile` where the admin's OAuth setup registered it, else the * `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 * webmail's client if it takes the app's redirect URI, else a public client
* the app registers itself, at `registrationEndpoint` when the OAuth * the app registers itself, at `registrationEndpoint` when the OAuth
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as * metadata names one. Either of the first two counts only if it allows
* before. A server having a bad moment (5xx, 429) gets the default without * `scope`, the scopes the sign-in will ask for. Any other server, and 0.16,
* it being remembered; an unreachable one throws `NetworkError`, as the * gets `bulwark-webmail` as before. A server having a bad moment (5xx, 429)
* default would only end in the server refusing a client it doesn't know. * 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( export async function resolveLoginClient(
serverUrl: string, serverUrl: string,
opts?: { registrationEndpoint?: string; signal?: AbortSignal }, opts?: { registrationEndpoint?: string; scope?: string; signal?: AbortSignal },
): Promise<LoginClient> { ): Promise<LoginClient> {
const base = trimBase(serverUrl); const base = trimBase(serverUrl);
const cached = loginClients.get(base); const cached = loginClients.get(base);
if (cached) return cached; if (cached) return cached;
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI }; const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
const signal = opts?.signal; const signal = opts?.signal;
const scope = opts?.scope ?? STALWART_LOGIN_SCOPE;
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, signal); const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, scope, signal);
if (own === 'busy') return fallback; if (own === 'busy') return fallback;
if (own === 'known') { if (own === 'known') {
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI }; const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
@@ -281,7 +308,7 @@ export async function resolveLoginClient(
return client; return client;
} }
if (own === 'unknown') { if (own === 'unknown') {
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, signal); const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, scope, signal);
if (shared === 'busy') return fallback; if (shared === 'busy') return fallback;
if (shared === 'unknown') { if (shared === 'unknown') {
const endpoint = opts?.registrationEndpoint; const endpoint = opts?.registrationEndpoint;
@@ -502,10 +529,15 @@ export async function exchangePasswordForTokens(
: await resolveLoginClient(base, { signal }); : await resolveLoginClient(base, { signal });
let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal); let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
// A remembered client the server no longer knows (another server now // A remembered client the server no longer knows (another server now
// answers at the address, or its key changed), or the default taken while // answers at the address, or its key changed) or no longer gives the
// the client check failed: ask again, once. 1.0 checks the client before // scopes (bulwark-mobile after the upgrade to 1.0.3), or the default taken
// the credentials, so this costs no failed sign-in. // while the client check failed: ask again, once. 1.0 checks the client
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) { // before the credentials, so this costs no failed sign-in.
if (
outcome.kind === 'rejected'
&& (outcome.error === 'invalid_client' || outcome.error === 'invalid_scope')
&& !opts?.clientId
) {
loginClients.delete(base); loginClients.delete(base);
const fresh = await resolveLoginClient(base, { signal }); const fresh = await resolveLoginClient(base, { signal });
if (fresh.clientId !== client.clientId) { if (fresh.clientId !== client.clientId) {