diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 0bcd565ea..85cc6930d 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -6,7 +6,9 @@ import { exchangeCodeForTokens, getRequiredConfig, getTokenEndpoint, + hasClientSecret, } from '@/lib/oauth/token-exchange'; +import { buildRedeemBundle } from '@/lib/auth/pair-bundle'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { storeIdToken } from '@/lib/oauth/end-session'; @@ -15,6 +17,20 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes +/** + * The webmail's public base (origin + mount path) from the callback URL the + * flow started with (`//auth/callback`, same-origin checked at + * /sso/start): where a phone renews through the token proxy. + */ +function webmailBaseFromCallback(redirectUri: string): string | null { + try { + const url = new URL(redirectUri); + return `${url.origin}${url.pathname.replace(/\/[^/]+\/auth\/callback\/?$/, '').replace(/\/+$/, '')}`; + } catch { + return null; + } +} + export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. @@ -113,16 +129,32 @@ export async function POST(request: NextRequest) { // The mobile client needs the bits it can't re-derive: the refresh // token, the token endpoint it should hit to refresh later, and the // client_id the IdP expects on that refresh call. The server URL is - // returned so the app knows which JMAP host to connect to. + // returned so the app knows which JMAP host to connect to. A phone + // cannot renew with a confidential client's secret, and current app + // builds refuse a provider on another host, so those renew through + // this webmail - the same bundle a paired phone gets. const { clientId, serverUrl } = getRequiredConfig(pendingServerId); - const tokenEndpoint = await getTokenEndpoint(pendingServerId); + const bundle = buildRedeemBundle({ + flow: 'oauth', + serverUrl, + serverId: pendingServerId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + issuedAt: Date.now(), + tokenEndpoint: await getTokenEndpoint(pendingServerId), + clientId, + confidential: hasClientSecret(pendingServerId), + trusted: true, + }, webmailBaseFromCallback(redirectUri)); + if (bundle.flow !== 'oauth') throw new Error('unexpected bundle'); return NextResponse.json({ - access_token: tokens.access_token, - expires_in: tokens.expires_in, - refresh_token: tokens.refresh_token, - token_endpoint: tokenEndpoint, - client_id: clientId, - server_url: serverUrl, + access_token: bundle.access_token, + expires_in: bundle.expires_in, + refresh_token: bundle.refresh_token, + token_endpoint: bundle.token_endpoint, + client_id: bundle.client_id, + server_url: bundle.server_url, mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState, }); diff --git a/lib/__tests__/sso-complete-refuses-reauth.test.ts b/lib/__tests__/sso-complete-route.test.ts similarity index 52% rename from lib/__tests__/sso-complete-refuses-reauth.test.ts rename to lib/__tests__/sso-complete-route.test.ts index 1edc3e069..db3d93d9f 100644 --- a/lib/__tests__/sso-complete-refuses-reauth.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; -// A pairing step-up (purpose `reauth`) completes at -// /api/auth/reauth/sso/complete only. Handed to the login route, its code -// would sign this browser in as whoever answered the provider's prompt. +// POST /api/auth/sso/complete. A pairing step-up (purpose `reauth`) +// completes at /api/auth/reauth/sso/complete only: handed to this route, its +// code would sign the browser in as whoever answered the provider's prompt. +// For the mobile app handoff, the phone gets a bundle it can renew. vi.mock('@/lib/auth/session-secret', () => ({ getSessionSecret: () => 's'.repeat(64), @@ -24,13 +25,17 @@ const cookieStore = { vi.mock('next/headers', () => ({ cookies: async () => cookieStore })); const exchangeCodeForTokens = vi.fn(); +let tokenEndpoint = 'https://idp.example.net/token'; +let confidential = false; vi.mock('@/lib/oauth/token-exchange', () => ({ exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args), - getTokenEndpoint: async () => 'https://idp.example.net/token', + getTokenEndpoint: async () => tokenEndpoint, getRequiredConfig: () => ({ clientId: 'webmail', serverUrl: 'https://mail.example.org', discoveryUrl: 'https://idp.example.net' }), + hasClientSecret: () => confidential, })); import { encryptPayload } from '@/lib/auth/crypto'; +import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle'; import { POST } from '@/app/api/auth/sso/complete/route'; function pending(extra: Record = {}) { @@ -54,6 +59,8 @@ async function complete() { beforeEach(() => { jar.clear(); + tokenEndpoint = 'https://idp.example.net/token'; + confidential = false; exchangeCodeForTokens.mockReset().mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }); }); @@ -75,3 +82,50 @@ describe('/api/auth/sso/complete', () => { expect(jar.get('jmap_rt')).toBe('rt'); }); }); + +describe('/api/auth/sso/complete for the mobile app handoff', () => { + // The app renews with client_id only, at a token endpoint on the mail + // server's or the webmail's host. Anything else goes through the webmail. + const mobile = () => pending({ + redirect_uri: 'https://webmail.example/mail/en/auth/callback', + mobile_redirect_uri: 'bulwarkmobile://auth/callback', + mobile_state: 'm-1', + }); + + it('hands a public client on the mail server straight to the app', async () => { + tokenEndpoint = 'https://mail.example.org/auth/token'; + mobile(); + const res = await complete(); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + refresh_token: 'rt', + token_endpoint: 'https://mail.example.org/auth/token', + client_id: 'webmail', + server_url: 'https://mail.example.org', + mobile_state: 'm-1', + }); + expect(jar.has('jmap_rt')).toBe(false); + }); + + it('renews a confidential client through the webmail, with a sealed token', async () => { + tokenEndpoint = 'https://mail.example.org/auth/token'; + confidential = true; + mobile(); + const res = await complete(); + expect(res.body.token_endpoint).toBe('https://webmail.example/mail/api/auth/pair/token'); + expect(res.body.refresh_token).not.toBe('rt'); + expect(openPhoneRefreshToken(res.body.refresh_token)).toMatchObject({ + refreshToken: 'rt', + clientId: 'webmail', + tokenEndpoint: 'https://mail.example.org/auth/token', + trusted: true, + }); + }); + + it('renews through the webmail when the provider is on a host the app refuses', async () => { + mobile(); + const res = await complete(); + expect(res.body.token_endpoint).toBe('https://webmail.example/mail/api/auth/pair/token'); + expect(openPhoneRefreshToken(res.body.refresh_token)?.tokenEndpoint).toBe('https://idp.example.net/token'); + }); +});