Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 2 additions & 18 deletions src/pages/api/mobile/auth/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
rememberChallenge,
signOutBrowserSession,
} from '~/server/mobileAuth';
import { isOwnNavigation } from '~/server/mobileAuthTokens';

const escapeHtml = (value: string) =>
value.replace(
Expand Down Expand Up @@ -58,11 +57,6 @@ const renderSignInPage = (provider: string, callbackUrl: string) =>
</script>`,
);

const START_FROM_APP_PAGE = renderPage(
'Sign in from the SplitPro app',
' <p>To sign in to the SplitPro app, open the app and start signing in there.</p>',
);

const querySchema = z.object({ provider: z.literal('google') });

export default mobileAuthRoute('GET', async (req, res) => {
Expand All @@ -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);
Expand Down
38 changes: 0 additions & 38 deletions src/server/mobileAuthTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
| {
Expand Down
22 changes: 3 additions & 19 deletions src/tests/mobileAuth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
{ '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, {
Expand Down
33 changes: 0 additions & 33 deletions src/tests/mobileAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
hasBearerAuthorization,
hashVerificationToken,
isJsonContentType,
isOwnNavigation,
isValidChallenge,
isValidHandoffCode,
parseAppleAudiences,
Expand Down Expand Up @@ -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);
});
});
Loading