diff --git a/src/pages/api/mobile/auth/start.ts b/src/pages/api/mobile/auth/start.ts index cbe59db..0ae512c 100644 --- a/src/pages/api/mobile/auth/start.ts +++ b/src/pages/api/mobile/auth/start.ts @@ -9,7 +9,6 @@ import { rememberChallenge, signOutBrowserSession, } from '~/server/mobileAuth'; -import { isOwnNavigation } from '~/server/mobileAuthTokens'; const escapeHtml = (value: string) => value.replace( @@ -58,11 +57,6 @@ const renderSignInPage = (provider: string, callbackUrl: string) => `, ); -const START_FROM_APP_PAGE = renderPage( - 'Sign in from the SplitPro app', - '

To sign in to the SplitPro app, open the app and start signing in there.

', -); - const querySchema = z.object({ provider: z.literal('google') }); export default mobileAuthRoute('GET', async (req, res) => { @@ -75,18 +69,8 @@ export default mobileAuthRoute('GET', async (req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); - // The app opens this in a browser of its own. A link from another site must not be able to sign - // the visitor out of the web, which is the first thing this does. - if (!isOwnNavigation(req.headers['sec-fetch-site'], req.headers.referer, env.NEXTAUTH_URL)) { - console.warn('Mobile sign-in start refused', { - secFetchSite: req.headers['sec-fetch-site'], - referer: req.headers.referer?.slice(0, 100), - userAgent: req.headers['user-agent']?.slice(0, 160), - }); - res.status(403).send(START_FROM_APP_PAGE); - return; - } - + // Sign the browser out first, so the handoff can only see the session Google creates next. This + // can't be limited to the app's own browser: Android's Custom Tabs look like any cross-site link. const callbackUrl = `/api/mobile/auth/handoff?${new URLSearchParams({ challenge, redirect }).toString()}`; await signOutBrowserSession(req, res); diff --git a/src/server/mobileAuthTokens.ts b/src/server/mobileAuthTokens.ts index 16683bc..a97d281 100644 --- a/src/server/mobileAuthTokens.ts +++ b/src/server/mobileAuthTokens.ts @@ -139,44 +139,6 @@ export const hasBearerAuthorization = (authorization: string | undefined) => export const isJsonContentType = (contentType: string | undefined) => 'application/json' === contentType?.split(';')[0]?.trim().toLowerCase(); -// Sec-Fetch-Site of a page the browser opened itself (`none`: the app's sign-in browser, a typed -// URL or a bookmark) or one of our own pages linked to -const OWN_NAVIGATION_SITES = ['none', 'same-origin']; - -/** - * Whether the browser opened the page itself or came from one of ours, rather than a link or form - * on another site. Browsers without Fetch Metadata (e.g. Safari before 16.4, which older iOS uses - * for the app's sign-in) are let through unless their Referer names another site: refusing them - * would break sign-in there, and a link can at worst sign such a browser out. - */ -const ANDROID_APP_REFERER = 'android-app://app.splitpro'; - -export const isOwnNavigation = ( - secFetchSite: string | string[] | undefined, - referer: string | undefined, - origin: string, -) => { - // Chrome Custom Tabs opened by our Android app send the app as the Referer (and mark the visit - // cross-site). Pages can't set an android-app:// Referer, only apps launching the browser can. - if (referer && referer.replace(/\/+$/, '') === ANDROID_APP_REFERER) { - return true; - } - - if (undefined !== secFetchSite) { - return 'string' === typeof secFetchSite && OWN_NAVIGATION_SITES.includes(secFetchSite); - } - - if (!referer) { - return true; - } - - try { - return new URL(referer).origin === new URL(origin).origin; - } catch { - return false; - } -}; - export type AppleFullName = | string | { diff --git a/src/tests/mobileAuth.integration.test.ts b/src/tests/mobileAuth.integration.test.ts index 59f2a52..1f7e94d 100644 --- a/src/tests/mobileAuth.integration.test.ts +++ b/src/tests/mobileAuth.integration.test.ts @@ -240,29 +240,13 @@ void test( }); assert.equal(expoGo.status, 400); - // A link or form on another site can't use /start to sign the browser out const existing = await createWebSession(Date.now() - DAY_MS); - for (const headers of [ - { 'sec-fetch-site': 'cross-site' } as Record, - { 'sec-fetch-site': 'same-site' }, - { referer: 'https://evil.example/' }, - ]) { - const linked = await callRoute(start, { - query: { provider: 'google', challenge, redirect }, - headers, - cookies: { 'next-auth.session-token': existing.sessionToken }, - }); - assert.equal(linked.status, 403, JSON.stringify(headers)); - assert.match(String(linked.body), /open the app and start signing in there/); - assert.deepEqual(linked.setCookies, []); - assert.equal(await db.session.count({ where: { id: existing.id } }), 1); - } - // Opened by the app's browser, /start signs it out of the web and asks Google which account - // to use. Browsers too old to send Sec-Fetch-Site are treated the same. + // /start signs the browser out of the web and asks Google which account to use, however the + // browser got there (Android Custom Tabs arrive as a cross-site navigation) const fromApp = await callRoute(start, { query: { provider: 'google', challenge, redirect }, - headers: { 'sec-fetch-site': 'none' }, + headers: { 'sec-fetch-site': 'cross-site' }, }); assert.equal(fromApp.status, 200); const started = await callRoute(start, { diff --git a/src/tests/mobileAuth.test.ts b/src/tests/mobileAuth.test.ts index 6ba72b6..575394c 100644 --- a/src/tests/mobileAuth.test.ts +++ b/src/tests/mobileAuth.test.ts @@ -13,7 +13,6 @@ import { hasBearerAuthorization, hashVerificationToken, isJsonContentType, - isOwnNavigation, isValidChallenge, isValidHandoffCode, parseAppleAudiences, @@ -273,35 +272,3 @@ void describe('formatAppleFullName', () => { assert.equal(formatAppleFullName(' '), null); }); }); - -void describe('isOwnNavigation', () => { - const origin = 'https://splitpro.app'; - - void it('allows what the browser opened itself or one of our pages linked to', () => { - assert.equal(isOwnNavigation('none', undefined, origin), true); - assert.equal(isOwnNavigation('same-origin', `${origin}/auth/signin`, origin), true); - }); - - void it('refuses links and forms from other sites, whatever the Referer says', () => { - assert.equal(isOwnNavigation('cross-site', undefined, origin), false); - assert.equal(isOwnNavigation('same-site', 'https://evil.splitpro.app/', origin), false); - assert.equal(isOwnNavigation('cross-site', `${origin}/`, origin), false); - assert.equal(isOwnNavigation('unknown', undefined, origin), false); - assert.equal(isOwnNavigation(['none', 'none'], undefined, origin), false); - }); - - void it('allows the Chrome Custom Tab our Android app opens, and no other app', () => { - assert.equal(isOwnNavigation('cross-site', 'android-app://app.splitpro/', origin), true); - assert.equal(isOwnNavigation('cross-site', 'android-app://app.splitpro', origin), true); - assert.equal(isOwnNavigation('cross-site', 'android-app://com.evil/', origin), false); - assert.equal(isOwnNavigation('cross-site', 'android-app://app.splitpro.evil/', origin), false); - }); - - void it('allows browsers without Fetch Metadata unless the Referer is another site', () => { - assert.equal(isOwnNavigation(undefined, undefined, origin), true); - assert.equal(isOwnNavigation(undefined, `${origin}/balances`, origin), true); - assert.equal(isOwnNavigation(undefined, 'https://evil.example/', origin), false); - assert.equal(isOwnNavigation(undefined, 'https://splitpro.app.evil.example/', origin), false); - assert.equal(isOwnNavigation(undefined, 'not a url', origin), false); - }); -});