fix: sign in through the server's own sign-in page on Stalwart 1.0

This commit is contained in:
Linus Rath
2026-10-29 02:07:46 +01:00
parent 96ef85159b
commit 6ffd0473e5
4 changed files with 122 additions and 9 deletions
+7
View File
@@ -47,6 +47,13 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmail"/> <data android:scheme="bulwarkmail"/>
</intent-filter> </intent-filter>
<!-- Browser sign-in callback of the client the app registers at Stalwart 1.0 (org.bulwarkmail.mobile:/auth/callback). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.bulwarkmail.mobile"/>
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/> <action android:name="android.intent.action.SENDTO"/>
+3 -1
View File
@@ -35,8 +35,10 @@ module.exports = {
slug: 'bulwark-mobile', slug: 'bulwark-mobile',
// mailto: lets Android/iOS offer the app for mail links in other apps; // mailto: lets Android/iOS offer the app for mail links in other apps;
// bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...). // 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. // 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, version: VERSION,
orientation: 'default', orientation: 'default',
icon: './assets/icon.png', icon: './assets/icon.png',
+62 -1
View File
@@ -5,8 +5,10 @@ vi.mock('../client-cert', () => ({
secureFetch: vi.fn(), 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 { HandoffCancelledError } from '../oauth';
import { REGISTERED_REDIRECT_URI, resetLoginClients } from '../totp-login';
const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as ReturnType<typeof vi.fn>; const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as ReturnType<typeof vi.fn>;
@@ -61,3 +63,62 @@ describe('loginWithPkce', () => {
expect(params.get('prompt')).toBe('select_account'); expect(params.get('prompt')).toBe('select_account');
}); });
}); });
describe('loginWithPkce on Stalwart', () => {
const fetchMock = secureFetch as unknown as ReturnType<typeof vi.fn>;
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');
});
});
+50 -7
View File
@@ -13,7 +13,12 @@
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert'; import { 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 { generateCodeChallenge, generateCodeVerifier, DEFAULT_CLIENT_ID } from './totp-login'; import {
generateCodeChallenge,
generateCodeVerifier,
resolveLoginClient,
DEFAULT_CLIENT_ID,
} from './totp-login';
import { randomHex } from './random'; import { randomHex } from './random';
export interface OAuthMetadata { export interface OAuthMetadata {
@@ -119,6 +124,45 @@ function addAccountPrompt(metadata: OAuthMetadata): string {
return 'select_account'; 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 * Run the authorization-code + PKCE flow in the system browser. Throws
* `HandoffCancelledError` when the user closes the browser. With `addAccount` * `HandoffCancelledError` when the user closes the browser. With `addAccount`
@@ -130,15 +174,14 @@ export async function loginWithPkce(
metadata: OAuthMetadata, metadata: OAuthMetadata,
opts?: { clientId?: string; scopes?: string; redirectUri?: string; addAccount?: boolean }, opts?: { clientId?: string; scopes?: string; redirectUri?: string; addAccount?: boolean },
): Promise<OAuthTokens> { ): Promise<OAuthTokens> {
const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID; const client = opts?.clientId
const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI; ? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI }
: await pkceClient(serverUrl, metadata);
const { clientId, redirectUri } = client;
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier); const challenge = generateCodeChallenge(verifier);
const state = randomHex(16); const state = randomHex(16);
const scope = opts?.scopes const scope = opts?.scopes ?? pkceScopes(metadata);
?? (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 params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',