diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 71324ad2..dc980690 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -47,6 +47,13 @@ + + + + + + + diff --git a/app.config.js b/app.config.js index 2e8dbadf..bf40cb11 100644 --- a/app.config.js +++ b/app.config.js @@ -35,8 +35,10 @@ module.exports = { slug: 'bulwark-mobile', // mailto: lets Android/iOS offer the app for mail links in other apps; // bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...). + // org.bulwarkmail.mobile: is where Stalwart 1.0 sends the browser sign-in + // back: it registers public clients only with a scheme that has a dot. // Android's manifest is maintained by hand and lists the same schemes. - scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'], + scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto', 'org.bulwarkmail.mobile'], version: VERSION, orientation: 'default', icon: './assets/icon.png', diff --git a/src/lib/__tests__/oauth-native.test.ts b/src/lib/__tests__/oauth-native.test.ts index 3f0b5bde..8c26ef6f 100644 --- a/src/lib/__tests__/oauth-native.test.ts +++ b/src/lib/__tests__/oauth-native.test.ts @@ -5,8 +5,10 @@ vi.mock('../client-cert', () => ({ secureFetch: vi.fn(), })); -import { loginWithPkce, type OAuthMetadata } from '../oauth-native'; +import { secureFetch } from '../client-cert'; +import { loginWithPkce, pkceScopes, type OAuthMetadata } from '../oauth-native'; import { HandoffCancelledError } from '../oauth'; +import { REGISTERED_REDIRECT_URI, resetLoginClients } from '../totp-login'; const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as ReturnType; @@ -61,3 +63,62 @@ describe('loginWithPkce', () => { expect(params.get('prompt')).toBe('select_account'); }); }); + +describe('loginWithPkce on Stalwart', () => { + const fetchMock = secureFetch as unknown as ReturnType; + const stalwart: OAuthMetadata = { + ...metadata, + authorization_endpoint: 'https://mail.example.com/login', + scopes_supported: [ + 'openid', + 'offline_access', + 'urn:ietf:params:oauth:scope:mail', + 'urn:ietf:params:oauth:scope:contacts', + 'urn:ietf:params:oauth:scope:calendars', + 'urn:ietf:params:oauth:scope:files', + ], + }; + const answer = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + + beforeEach(() => { + vi.clearAllMocks(); + resetLoginClients(); + }); + + it('signs in on 1.0 with a client it registers, on a redirect 1.0 accepts', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('/api/auth/client')) return answer({ error: 'invalid_client', description: 'Unknown client id' }); + if (url.endsWith('/auth/register')) return answer({ client_id: 'swc1.abc' }, 201); + return answer({}, 404); + }); + const { params, redirectUri } = await openedSession(stalwart); + + expect(params.get('client_id')).toBe('swc1.abc'); + expect(params.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); + expect(redirectUri).toBe(REGISTERED_REDIRECT_URI); + }); + + it('keeps the webmail client and the app scheme on 0.16', async () => { + fetchMock.mockImplementation(async () => answer({ status: 404 }, 404)); + const { params, redirectUri } = await openedSession(stalwart); + + expect(params.get('client_id')).toBe('bulwark-webmail'); + expect(redirectUri).toBe('bulwarkmobile://auth/callback'); + }); + + it("asks for Stalwart's data scopes and none it doesn't list", async () => { + fetchMock.mockImplementation(async () => answer({ status: 404 }, 404)); + const { params } = await openedSession(stalwart); + const scopes = params.get('scope')!.split(' '); + + expect(scopes).toEqual(expect.arrayContaining(['openid', 'offline_access', 'urn:ietf:params:oauth:scope:mail', 'urn:ietf:params:oauth:scope:files'])); + expect(scopes).not.toContain('email'); + expect(scopes).not.toContain('profile'); + }); + + it('asks another identity provider for the usual OpenID scopes', () => { + expect(pkceScopes({ ...metadata, scopes_supported: ['openid', 'email', 'profile', 'offline_access', 'groups'] })) + .toBe('openid email profile offline_access'); + }); +}); diff --git a/src/lib/oauth-native.ts b/src/lib/oauth-native.ts index 7f7d09de..f5df3754 100644 --- a/src/lib/oauth-native.ts +++ b/src/lib/oauth-native.ts @@ -13,7 +13,12 @@ import * as WebBrowser from 'expo-web-browser'; import { secureFetch } from './client-cert'; import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth'; -import { generateCodeChallenge, generateCodeVerifier, DEFAULT_CLIENT_ID } from './totp-login'; +import { + generateCodeChallenge, + generateCodeVerifier, + resolveLoginClient, + DEFAULT_CLIENT_ID, +} from './totp-login'; import { randomHex } from './random'; export interface OAuthMetadata { @@ -119,6 +124,45 @@ function addAccountPrompt(metadata: OAuthMetadata): string { return 'select_account'; } +const DATA_SCOPE_PREFIX = 'urn:ietf:params:oauth:scope:'; + +/** + * Scopes to ask for (webmail lib/oauth/scopes.ts). A provider that + * 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 + * 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; + * any other provider gets the usual OIDC set. + */ +export function pkceScopes(metadata: OAuthMetadata): string { + const wanted = ['openid', 'email', 'profile', 'offline_access']; + const supported = metadata.scopes_supported; + if (!supported?.length) return wanted.join(' '); + const kept = wanted.filter((s) => supported.includes(s)); + const data = supported.filter((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX)); + if (data.length) return [...new Set([...kept, ...data])].join(' '); + return kept.join(' ') || supported.join(' '); +} + +/** + * The client the browser sign-in uses. When the server is its own + * authorization server (Stalwart), the same check as the password sign-in + * decides: 0.16 takes `bulwark-webmail` on `bulwarkmobile://`, 1.0 knows no + * such client and gets one the app registers. An external identity provider + * keeps the webmail's client id. + */ +async function pkceClient(serverUrl: string, metadata: OAuthMetadata): Promise<{ clientId: string; redirectUri: string }> { + if (sameHost(metadata.authorization_endpoint, serverUrl) && sameHost(metadata.token_endpoint, serverUrl)) { + try { + return await resolveLoginClient(serverUrl); + } catch { + // A refused registration or a redirect away: the browser shows what + // the server says about the default client. + } + } + return { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI }; +} + /** * Run the authorization-code + PKCE flow in the system browser. Throws * `HandoffCancelledError` when the user closes the browser. With `addAccount` @@ -130,15 +174,14 @@ export async function loginWithPkce( metadata: OAuthMetadata, opts?: { clientId?: string; scopes?: string; redirectUri?: string; addAccount?: boolean }, ): Promise { - const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID; - const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI; + const client = opts?.clientId + ? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI } + : await pkceClient(serverUrl, metadata); + const { clientId, redirectUri } = client; const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); const state = randomHex(16); - const scope = opts?.scopes - ?? (metadata.scopes_supported?.length - ? ['openid', 'email', 'profile', 'offline_access'].filter((s) => metadata.scopes_supported!.includes(s)).join(' ') || metadata.scopes_supported.join(' ') - : 'openid email profile offline_access'); + const scope = opts?.scopes ?? pkceScopes(metadata); const params = new URLSearchParams({ response_type: 'code',