Files
webmail/lib/__tests__/pair-bundle.test.ts
T
Linus Rath 1446fd39dc fix: link the mobile app from every account with a sign-in of its own
"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.

The step-up now mints the phone's own grant:

- Password and password+TOTP accounts confirm the current password (and
  code) in the dialog. The server uses it once to get an OAuth grant
  from Stalwart's structured login for the app's own client, retries
  with the webmail's client when the server insists on a registered one,
  and falls back to an app password ("Bulwark mobile (paired <date>)")
  or, on servers that offer neither, the verified password. Failed
  attempts are limited per account so the webmail's address cannot be
  banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
  where offered; its fresh tokens become the phone's grant instead of
  being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
  context, never from the request.

Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.

The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
2026-09-27 04:16:18 +02:00

225 lines
9.8 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
// What a paired phone receives: the provider's own token endpoint when the
// app can talk to it directly (public client, a host the app accepts), else
// the webmail's token proxy with the refresh token sealed under
// SESSION_SECRET for the `pair-refresh` purpose.
let sessionSecret = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => sessionSecret,
hasSessionSecret: () => sessionSecret.length > 0,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
import {
PAIR_TOKEN_PROXY_PATH,
appAcceptsTokenEndpoint,
buildRedeemBundle,
openPhoneRefreshToken,
sealPhoneRefreshToken,
type SealedRefresh,
} from '@/lib/auth/pair-bundle';
import { encryptPayload } from '@/lib/auth/crypto';
import type { PairingGrant } from '@/lib/auth/pairing-store';
const SERVER = 'https://mail.example.org';
const WEBMAIL = 'https://webmail.example.net/mail';
const NOW = 1_800_000_000_000;
function oauthGrant(overrides: Partial<Extract<PairingGrant, { flow: 'oauth' }>> = {}): PairingGrant {
return {
flow: 'oauth',
serverUrl: SERVER,
serverId: null,
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
issuedAt: NOW,
tokenEndpoint: `${SERVER}/auth/token`,
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
...overrides,
};
}
beforeEach(() => {
sessionSecret = 's'.repeat(64);
});
describe('appAcceptsTokenEndpoint (mirror of the app rule)', () => {
const cases: Array<[string, string, string, string | null, boolean]> = [
['same host as the JMAP server', `${SERVER}/auth/token`, SERVER, null, true],
['same host, other port', 'https://mail.example.org:8443/auth/token', SERVER, null, true],
['host case is ignored', 'HTTPS://MAIL.EXAMPLE.ORG/auth/token', SERVER, null, true],
['subdomain of the JMAP server', 'https://auth.mail.example.org/token', SERVER, null, true],
['parent domain of the JMAP server', 'https://example.org/token', SERVER, null, true],
['sibling subdomain', 'https://idp.example.org/token', SERVER, null, false],
['look-alike suffix', 'https://evilmail.example.org/token', SERVER, null, false],
['unrelated host', 'https://idp.example.com/token', SERVER, null, false],
['the webmail host', 'https://webmail.example.net/api/auth/pair/token', SERVER, WEBMAIL, true],
['subdomain of the webmail host', 'https://sso.webmail.example.net/token', SERVER, WEBMAIL, true],
['sibling of the webmail host', 'https://idp.example.net/token', SERVER, WEBMAIL, false],
['http on a public host', 'http://mail.example.org/auth/token', SERVER, null, false],
['http on localhost', 'http://localhost:8080/auth/token', 'http://localhost:8080', null, true],
['http on 127.0.0.1', 'http://127.0.0.1:8080/auth/token', 'http://127.0.0.1:8080', null, true],
['http on the Android emulator host', 'http://10.0.2.2:8080/auth/token', 'http://10.0.2.2:8080', null, true],
['http loopback, but not the server host', 'http://localhost:8080/auth/token', SERVER, null, false],
['other private http host', 'http://192.168.1.10/auth/token', 'http://192.168.1.10', null, false],
['not a URL', 'not a url', SERVER, null, false],
['other scheme', 'ftp://mail.example.org/token', SERVER, null, false],
];
for (const [label, endpoint, server, webmail, expected] of cases) {
it(`${expected ? 'accepts' : 'rejects'} ${label}`, () => {
expect(appAcceptsTokenEndpoint(endpoint, server, webmail)).toBe(expected);
});
}
});
describe('buildRedeemBundle', () => {
it('passes a password grant through as-is', () => {
const bundle = buildRedeemBundle(
{ flow: 'password', serverUrl: SERVER, username: 'alice@example.org', password: 'app_x', credential: 'app-password' },
WEBMAIL,
NOW,
);
expect(bundle).toEqual({ flow: 'password', server_url: SERVER, username: 'alice@example.org', password: 'app_x' });
});
it('points a public client on the server host at the provider directly, with the raw refresh token', () => {
const bundle = buildRedeemBundle(oauthGrant(), WEBMAIL, NOW);
expect(bundle).toEqual({
flow: 'oauth',
server_url: SERVER,
access_token: 'access-1',
refresh_token: 'refresh-1',
expires_in: 3600,
token_endpoint: `${SERVER}/auth/token`,
client_id: 'bulwark-webmail',
});
});
it('reduces expires_in by the time elapsed since the tokens were issued', () => {
expect(buildRedeemBundle(oauthGrant(), WEBMAIL, NOW + 100_900)).toMatchObject({ expires_in: 3500 });
expect(buildRedeemBundle(oauthGrant({ expiresIn: 60 }), WEBMAIL, NOW + 120_000)).toMatchObject({ expires_in: 0 });
expect(buildRedeemBundle(oauthGrant({ expiresIn: undefined }), WEBMAIL, NOW)).not.toHaveProperty('expires_in');
});
it('leaves out a refresh token the provider did not issue', () => {
expect(buildRedeemBundle(oauthGrant({ refreshToken: undefined }), WEBMAIL, NOW)).not.toHaveProperty('refresh_token');
expect(
buildRedeemBundle(oauthGrant({ refreshToken: undefined, confidential: true }), WEBMAIL, NOW),
).not.toHaveProperty('refresh_token');
});
it('sends a confidential client through the webmail proxy with a sealed refresh token', () => {
const grant = oauthGrant({ confidential: true, serverId: 'srv-1' });
const bundle = buildRedeemBundle(grant, `${WEBMAIL}//`, NOW);
expect(bundle.flow).toBe('oauth');
if (bundle.flow !== 'oauth') return;
expect(bundle.token_endpoint).toBe(`${WEBMAIL}${PAIR_TOKEN_PROXY_PATH}`);
expect(bundle.client_id).toBe('bulwark-webmail');
expect(bundle.access_token).toBe('access-1');
expect(bundle.refresh_token).toBeDefined();
expect(bundle.refresh_token).not.toContain('refresh-1');
expect(openPhoneRefreshToken(bundle.refresh_token!)).toEqual({
refreshToken: 'refresh-1',
serverId: 'srv-1',
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
});
});
it('proxies a public client whose token endpoint the app would refuse', () => {
const grant = oauthGrant({ tokenEndpoint: 'https://idp.example.com/realms/x/token', clientId: 'webmail' });
const bundle = buildRedeemBundle(grant, WEBMAIL, NOW);
if (bundle.flow !== 'oauth') throw new Error('expected oauth');
expect(bundle.token_endpoint).toBe(`${WEBMAIL}${PAIR_TOKEN_PROXY_PATH}`);
expect(openPhoneRefreshToken(bundle.refresh_token!)).toMatchObject({
refreshToken: 'refresh-1',
tokenEndpoint: 'https://idp.example.com/realms/x/token',
clientId: 'webmail',
});
});
it('goes direct to an IdP on the webmail host family', () => {
const grant = oauthGrant({ tokenEndpoint: 'https://sso.webmail.example.net/token' });
const bundle = buildRedeemBundle(grant, WEBMAIL, NOW);
expect(bundle).toMatchObject({ token_endpoint: 'https://sso.webmail.example.net/token', refresh_token: 'refresh-1' });
});
it('keeps the trust flag of a user-chosen server in the sealed token', () => {
const bundle = buildRedeemBundle(oauthGrant({ confidential: true, trusted: false }), WEBMAIL, NOW);
if (bundle.flow !== 'oauth') throw new Error('expected oauth');
expect(openPhoneRefreshToken(bundle.refresh_token!)?.trusted).toBe(false);
});
});
describe('sealed phone refresh tokens', () => {
const VALUE: SealedRefresh = {
refreshToken: 'refresh-1',
serverId: null,
clientId: 'bulwark-webmail',
tokenEndpoint: `${SERVER}/auth/token`,
trusted: true,
};
it('round-trip', () => {
expect(openPhoneRefreshToken(sealPhoneRefreshToken(VALUE))).toEqual(VALUE);
const other = { ...VALUE, serverId: 'srv-2', trusted: false };
expect(openPhoneRefreshToken(sealPhoneRefreshToken(other))).toEqual(other);
});
it('are opaque', () => {
const sealed = sealPhoneRefreshToken(VALUE);
expect(sealed).not.toContain('refresh-1');
expect(Buffer.from(sealed, 'base64').toString('latin1')).not.toContain('refresh-1');
expect(sealPhoneRefreshToken(VALUE)).not.toBe(sealed);
});
it('do not open from garbage, a tampered blob or an empty string', () => {
expect(openPhoneRefreshToken('')).toBeNull();
expect(openPhoneRefreshToken('garbage')).toBeNull();
expect(openPhoneRefreshToken('refresh-1')).toBeNull();
const sealed = Buffer.from(sealPhoneRefreshToken(VALUE), 'base64');
sealed[sealed.length - 1] ^= 0x01;
expect(openPhoneRefreshToken(sealed.toString('base64'))).toBeNull();
});
it('do not open from a blob sealed for another purpose', () => {
const fields = { r: 'refresh-1', s: null, c: 'bulwark-webmail', e: `${SERVER}/auth/token`, t: true };
expect(openPhoneRefreshToken(encryptPayload(fields, 'pair-reauth'))).toBeNull();
expect(openPhoneRefreshToken(encryptPayload(fields, 'session-context'))).toBeNull();
expect(openPhoneRefreshToken(encryptPayload(fields, 'sso-pending'))).toBeNull();
// The same fields under the right purpose do open.
expect(openPhoneRefreshToken(encryptPayload(fields, 'pair-refresh'))).toEqual(VALUE);
});
it('do not open under another SESSION_SECRET', () => {
const sealed = sealPhoneRefreshToken(VALUE);
sessionSecret = 't'.repeat(64);
expect(openPhoneRefreshToken(sealed)).toBeNull();
});
it('reject payloads with missing or malformed fields', () => {
const good = { r: 'refresh-1', s: null, c: 'bulwark-webmail', e: `${SERVER}/auth/token`, t: true };
for (const bad of [
{ ...good, r: '' },
{ ...good, r: 1 },
{ ...good, c: '' },
{ ...good, e: '' },
{ ...good, s: 5 },
]) {
expect(openPhoneRefreshToken(encryptPayload(bad, 'pair-refresh'))).toBeNull();
}
// Only an explicit `true` counts as trusted.
expect(openPhoneRefreshToken(encryptPayload({ ...good, t: 'yes' }, 'pair-refresh'))?.trusted).toBe(false);
});
});