From 6ffd0473e58f38021758cc47bf06b3d9c6479809 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 02:07:46 +0100
Subject: [PATCH] fix: sign in through the server's own sign-in page on
Stalwart 1.0
---
android/app/src/main/AndroidManifest.xml | 7 +++
app.config.js | 4 +-
src/lib/__tests__/oauth-native.test.ts | 63 +++++++++++++++++++++++-
src/lib/oauth-native.ts | 57 ++++++++++++++++++---
4 files changed, 122 insertions(+), 9 deletions(-)
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',