mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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"/>
|
<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
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user