mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: sign in through the server's own sign-in page on Stalwart 1.0
This commit is contained in:
@@ -47,6 +47,13 @@
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmail"/>
|
||||
</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>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SENDTO"/>
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
@@ -61,3 +63,62 @@ describe('loginWithPkce', () => {
|
||||
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
@@ -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<OAuthTokens> {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user