diff --git a/js/Discourse.js b/js/Discourse.js index 6da48f6b0..e3ef0ce37 100644 --- a/js/Discourse.js +++ b/js/Discourse.js @@ -102,6 +102,11 @@ import NativeTopicScreen from './product/NativeTopicScreen'; import NativeCollectionScreen from './product/NativeCollectionScreen'; import NativeProfileScreen from './product/NativeProfileScreen'; import { classifyFirstPartyMemberRoute } from './nativeMemberRouting'; +import { + WEB_SESSION_UNAVAILABLE, + destinationPresentation, +} from './notificationDestination'; +import { resolveWebSessionEntry } from './webViewSession'; import { consumePendingShareIntent } from './shareIntentCoordinator'; import { loadOnboardingState, @@ -970,34 +975,61 @@ class Discourse extends React.Component { authenticated: Boolean(site), isStaff: Boolean(site?.isStaff), }); - if (route.disposition === 'native') { + const presentation = destinationPresentation(route); + if (presentation.kind === 'native') { this._siteManager.setActiveSite(site); - if (route.screen === 'Ask') { + if (presentation.screen === 'Ask') { this._navigation.navigate('HomeWrapper', { screen: 'Ask' }); } else { - this._navigation.navigate(route.screen, route.params); + this._navigation.navigate(presentation.screen, presentation.params); } return; } - // A valid first-party member destination with no native screen opens in the - // authenticated Discourse WebView. Without this branch such destinations - // fell through and the tap did nothing at all: notification read-marking - // had already succeeded, so a granted_badge notification went read with no - // visible result. Every disposition is now handled explicitly. - if (route.disposition === 'first_party_web') { - this._siteManager.setActiveSite(site); - this._navigation.navigate('WebView', { url: route.url }); + if (presentation.kind === 'web') { + this._openFirstPartyWeb(site, presentation.url); return; } - if (route.disposition === 'privileged_external') { - Linking.openURL(route.url).catch(() => {}); + if (presentation.kind === 'external') { + Linking.openURL(presentation.url).catch(() => {}); return; } - // 'rejected' is a deliberate denial: off-origin, unauthenticated, a - // non-staff admin path, or an unrecognised destination. Nothing opens. + // Denied: off-origin, unauthenticated, a non-staff admin path, or an + // unrecognised destination. Nothing opens and nothing loads. securityEvent('navigation.rejected'); } + // A first-party member page needs a Discourse session cookie, which the + // WebView does not get from the User API key. Bootstrap one through the + // supported OTP contract when it is missing, then land on the destination. + // Any failure or cancellation ends in a bounded, explicit state rather than + // a blank WebView. + async _openFirstPartyWeb(site, destination) { + try { + this._siteManager.setActiveSite(site); + const entry = await resolveWebSessionEntry( + site, + this._siteManager, + destination, + ); + securityEvent( + entry.destination + ? 'navigation.web_session_bootstrap' + : 'navigation.web_session_reused', + ); + this._navigation.navigate('WebView', { + url: entry.url, + destination: entry.destination, + }); + } catch { + securityEvent('navigation.web_session_unavailable'); + Alert.alert( + WEB_SESSION_UNAVAILABLE.title, + WEB_SESSION_UNAVAILABLE.message, + [{ text: WEB_SESSION_UNAVAILABLE.close, style: 'cancel' }], + ); + } + } + // A member must never be trapped behind an identity they did not choose in // this attempt. Retire every client-side identity carrier, then start a // normal authorization. This does not depend on the browser honouring an diff --git a/js/__tests__/notificationRouting.test.js b/js/__tests__/notificationRouting.test.js index 05a1ad7cb..fcea64e80 100644 --- a/js/__tests__/notificationRouting.test.js +++ b/js/__tests__/notificationRouting.test.js @@ -242,26 +242,28 @@ describe('the tap handler marks read before navigating and has no silent path', expect(handler).toContain('openUrl(url)'); }); - test('openUrl handles every disposition explicitly', () => { + test('openUrl handles every presentation explicitly', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'Discourse.js'), 'utf8', ); const openUrl = source.slice( source.indexOf(' openUrl(url) {'), - source.indexOf(' _toggleTheme('), + source.indexOf(' async _openFirstPartyWeb('), ); - for (const disposition of [ - 'native', - 'first_party_web', - 'privileged_external', - ]) { - expect(openUrl).toContain(`route.disposition === '${disposition}'`); + // Dispositions are mapped by the pure destinationPresentation module and + // openUrl branches on the resulting kind. Every kind is handled. + expect(openUrl).toContain('destinationPresentation(route)'); + for (const kind of ['native', 'web', 'external']) { + expect(openUrl).toContain(`presentation.kind === '${kind}'`); } - // The rejected path is explicit, not an implicit fallthrough. + // The denied path is explicit, not an implicit fallthrough. expect(openUrl).toContain("securityEvent('navigation.rejected')"); + // A first-party web destination is never loaded without first resolving + // an authenticated Discourse session. + expect(openUrl).not.toContain("navigate('WebView'"); expect(openUrl).toContain( - "this._navigation.navigate('WebView', { url: route.url })", + 'this._openFirstPartyWeb(site, presentation.url)', ); }); }); diff --git a/js/__tests__/webViewSession.test.js b/js/__tests__/webViewSession.test.js new file mode 100644 index 000000000..05f338117 --- /dev/null +++ b/js/__tests__/webViewSession.test.js @@ -0,0 +1,380 @@ +jest.mock('@react-native-cookies/cookies', () => ({ get: jest.fn() })); + +import CookieManager from '@react-native-cookies/cookies'; +import DiscourseUtils from '../DiscourseUtils'; +import { classifyFirstPartyMemberRoute } from '../nativeMemberRouting'; +import { + WEB_SESSION_UNAVAILABLE, + destinationPresentation, +} from '../notificationDestination'; +import { + OTP_ENDPOINT, + hasAuthenticatedWebSession, + isOtpBootstrapUrl, + otpBootstrapUrl, + requestOneTimePassword, + resolveWebSessionEntry, +} from '../webViewSession'; + +const ORIGIN = 'https://adjusternetwork.org'; +const OTP = 'a1b2c3d4e5f6'; +const BADGE = `${ORIGIN}/badges/9/basic?username=tomrodriguez`; + +const makeSite = (overrides = {}) => ({ + url: ORIGIN, + username: 'tomrodriguez', + authToken: 'user-api-key', + clientId: 'client-A', + jsonApi: jest.fn(), + ...overrides, +}); + +const makeManager = (overrides = {}) => ({ + ensureRSAKeys: jest.fn(() => Promise.resolve()), + rsaKeys: { public: 'PUBLIC-KEY', private: 'PRIVATE-KEY' }, + decryptHelper: jest.fn(() => OTP), + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + CookieManager.get.mockResolvedValue({}); +}); + +describe('OTP request uses the existing credentials and crypto', () => { + test('posts the app public key, governed redirect and pkcs1 padding', async () => { + const site = makeSite(); + const manager = makeManager(); + site.jsonApi.mockResolvedValue({ + redirect_url: `adjusternetwork://adjusternetwork.org/auth_redirect?oneTimePassword=ENCRYPTED`, + }); + + await expect(requestOneTimePassword(site, manager)).resolves.toBe(OTP); + + // Reuses site.jsonApi, so User-Api-Key / User-Api-Client-Id headers and + // the rate-limit buckets apply unchanged. + expect(site.jsonApi).toHaveBeenCalledWith(OTP_ENDPOINT, 'POST', { + public_key: 'PUBLIC-KEY', + auth_redirect: 'adjusternetwork://adjusternetwork.org/auth_redirect', + padding: 'pkcs1', + }); + // Same RSA machinery as the authorization flow; no second implementation. + expect(manager.ensureRSAKeys).toHaveBeenCalled(); + expect(manager.decryptHelper).toHaveBeenCalledWith('ENCRYPTED'); + }); + + test('an unauthenticated site never requests an OTP', async () => { + const site = makeSite({ authToken: null }); + await expect(requestOneTimePassword(site, makeManager())).rejects.toThrow( + 'web_session_unauthenticated', + ); + expect(site.jsonApi).not.toHaveBeenCalled(); + }); + + test('a missing RSA public key fails before any request', async () => { + const site = makeSite(); + await expect( + requestOneTimePassword(site, makeManager({ rsaKeys: {} })), + ).rejects.toThrow('web_session_key_unavailable'); + expect(site.jsonApi).not.toHaveBeenCalled(); + }); + + test('a response without an OTP fails closed', async () => { + const site = makeSite(); + for (const redirect_url of [ + undefined, + '', + 'adjusternetwork://adjusternetwork.org/auth_redirect', + 'https://evil.example.com/?oneTimePassword=X', + ]) { + site.jsonApi.mockResolvedValue({ redirect_url }); + await expect(requestOneTimePassword(site, makeManager())).rejects.toThrow( + 'web_session_otp_missing', + ); + } + }); + + test('a non-hex decrypted OTP is refused so nothing is injected into the path', async () => { + const site = makeSite(); + site.jsonApi.mockResolvedValue({ + redirect_url: `adjusternetwork://adjusternetwork.org/auth_redirect?oneTimePassword=E`, + }); + for (const bad of ['../../admin', 'abc/def', 'ZZZZ', '', null]) { + await expect( + requestOneTimePassword(site, makeManager({ decryptHelper: () => bad })), + ).rejects.toThrow('web_session_otp_invalid'); + } + }); + + test('a rate-limited or failing OTP request propagates', async () => { + const site = makeSite(); + site.jsonApi.mockRejectedValue( + Object.assign(new Error('api_rate_limited'), { status: 429 }), + ); + await expect(requestOneTimePassword(site, makeManager())).rejects.toThrow( + 'api_rate_limited', + ); + }); +}); + +describe('bootstrap URL construction is constrained', () => { + test('builds the confirmation route for a hex token only', () => { + expect(otpBootstrapUrl({ url: ORIGIN }, OTP)).toBe( + `${ORIGIN}/session/otp/${OTP}`, + ); + for (const bad of ['../admin', 'a/b', 'ZZ', '', null, undefined]) { + expect(otpBootstrapUrl({ url: ORIGIN }, bad)).toBeNull(); + } + expect(otpBootstrapUrl(null, OTP)).toBeNull(); + }); + + test('recognises only canonical-origin HTTPS bootstrap URLs', () => { + expect(isOtpBootstrapUrl(`${ORIGIN}/session/otp/${OTP}`)).toBe(true); + for (const bad of [ + `${ORIGIN}/badges/9/basic`, + `https://evil.example.com/session/otp/${OTP}`, + `http://adjusternetwork.org/session/otp/${OTP}`, + `https://adjusternetwork.org.evil.example.com/session/otp/${OTP}`, + 'not-a-url', + null, + ]) { + expect(isOtpBootstrapUrl(bad)).toBe(false); + } + }); +}); + +describe('an existing session is reused rather than minting another OTP', () => { + test('a live auth cookie skips the bootstrap entirely', async () => { + CookieManager.get.mockResolvedValue({ _t: { value: 'session-token' } }); + const site = makeSite(); + await expect(hasAuthenticatedWebSession(site, CookieManager)).resolves.toBe( + true, + ); + + await expect( + resolveWebSessionEntry(site, makeManager(), BADGE), + ).resolves.toEqual({ url: BADGE, destination: null }); + expect(site.jsonApi).not.toHaveBeenCalled(); + }); + + test('an absent or empty cookie bootstraps and remembers the destination', async () => { + for (const jar of [{}, { _t: {} }, { _t: { value: '' } }, null]) { + CookieManager.get.mockResolvedValue(jar); + const site = makeSite(); + site.jsonApi.mockResolvedValue({ + redirect_url: `adjusternetwork://adjusternetwork.org/auth_redirect?oneTimePassword=E`, + }); + await expect( + resolveWebSessionEntry(site, makeManager(), BADGE), + ).resolves.toEqual({ + url: `${ORIGIN}/session/otp/${OTP}`, + destination: BADGE, + }); + expect(site.jsonApi).toHaveBeenCalledTimes(1); + } + }); + + test('an unreadable cookie jar bootstraps rather than assuming a session', async () => { + CookieManager.get.mockRejectedValue(new Error('cookie failure')); + await expect( + hasAuthenticatedWebSession(makeSite(), CookieManager), + ).resolves.toBe(false); + }); + + test('an off-origin destination is refused before any OTP is minted', async () => { + const site = makeSite(); + for (const bad of [ + 'https://evil.example.com/badges/9/basic', + 'http://adjusternetwork.org/badges/9/basic', + null, + ]) { + await expect( + resolveWebSessionEntry(site, makeManager(), bad), + ).rejects.toThrow('web_session_destination'); + } + expect(site.jsonApi).not.toHaveBeenCalled(); + }); +}); + +describe('destination presentation', () => { + const site = { url: ORIGIN, username: 'tomrodriguez' }; + const member = { authenticated: true, isStaff: false }; + const present = (n, o = member) => + destinationPresentation( + classifyFirstPartyMemberRoute( + DiscourseUtils.endpointForSiteNotification(site, n), + o, + ), + ); + + test('granted_badge presents a web destination', () => { + expect( + present({ + notification_type: 12, + topic_id: null, + post_number: null, + data: { badge_id: 9, username: 'tomrodriguez' }, + }), + ).toEqual({ kind: 'web', url: BADGE }); + }); + + test.each([ + [ + 'group_message_summary', + { + notification_type: 16, + data: { username: 'tomrodriguez', group_name: 'staff' }, + }, + ], + [ + 'liked_consolidated', + { notification_type: 19, data: { username: 'someone' } }, + ], + [ + 'membership_request_accepted', + { notification_type: 22, data: { group_name: 'staff' } }, + ], + [ + 'chat_mention', + { + notification_type: 29, + data: { + chat_channel_id: 2, + chat_channel_title: 'lounge', + chat_message_id: 9, + }, + }, + ], + [ + 'chat_message', + { + notification_type: 30, + data: { chat_channel_id: 2, chat_channel_title: 'lounge' }, + }, + ], + ])('%s presents a web destination', (_l, n) => { + expect(present(n).kind).toBe('web'); + }); + + test('native notification routing is unchanged', () => { + expect( + present({ + notification_type: 2, + slug: 't', + topic_id: 4, + post_number: 1, + data: {}, + }), + ).toMatchObject({ kind: 'native', screen: 'Topic' }); + expect( + present({ notification_type: 800, data: { display_username: 'x' } }), + ).toMatchObject({ kind: 'native', screen: 'MemberProfile' }); + }); + + test('denied destinations stay denied', () => { + expect(present({ notification_type: 37, data: {} })).toEqual({ + kind: 'denied', + }); + expect(present({ notification_type: 999, data: {} })).toEqual({ + kind: 'denied', + }); + expect( + present({ + notification_type: 12, + data: { badge_id: 'abc', username: 'x' }, + }), + ).toEqual({ kind: 'denied' }); + expect( + destinationPresentation( + classifyFirstPartyMemberRoute(BADGE, { authenticated: false }), + ), + ).toEqual({ kind: 'denied' }); + expect(destinationPresentation(null)).toEqual({ kind: 'denied' }); + }); + + test('staff admin still hands off externally', () => { + expect( + present( + { notification_type: 37, data: {} }, + { authenticated: true, isStaff: true }, + ), + ).toEqual({ kind: 'external', url: `${ORIGIN}/admin` }); + }); +}); + +describe('failure is bounded and explicit', () => { + test('the failure copy promises no loading and no login', () => { + expect(WEB_SESSION_UNAVAILABLE.close).toBe('Close'); + expect(WEB_SESSION_UNAVAILABLE.message).toMatch(/marked as read/i); + expect(WEB_SESSION_UNAVAILABLE.message).not.toMatch( + /log ?in|sign ?in|browser|Safari/i, + ); + }); +}); + +describe('wiring', () => { + const fs = require('fs'); + const path = require('path'); + const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); + + test('openUrl delegates web destinations and bounds failure', () => { + const source = read('Discourse.js'); + const openUrl = source.slice( + source.indexOf(' openUrl(url) {'), + source.indexOf(' async _openFirstPartyWeb('), + ); + expect(openUrl).toContain("presentation.kind === 'web'"); + expect(openUrl).toContain( + 'this._openFirstPartyWeb(site, presentation.url)', + ); + expect(openUrl).toContain("securityEvent('navigation.rejected')"); + // openUrl itself never opens the WebView: a web destination must go + // through session resolution first. + expect(openUrl).not.toContain("navigate('WebView'"); + + const handler = source.slice( + source.indexOf(' async _openFirstPartyWeb('), + source.indexOf(' _toggleTheme('), + ); + // The WebView is reached only after the session entry resolves, and any + // failure ends in the bounded explicit state. + expect(handler.indexOf('resolveWebSessionEntry(')).toBeLessThan( + handler.indexOf("navigate('WebView'"), + ); + expect(handler).toContain( + "securityEvent('navigation.web_session_unavailable')", + ); + expect(handler).toContain('WEB_SESSION_UNAVAILABLE.title'); + }); + + test('the WebView policy relaxation is bootstrap-scoped, not standing', () => { + const source = read('screens/WebViewScreenComponents/WebViewComponent.js'); + // The original guard survives. + expect(source).toContain( + '// Canonical pages without an explicit native route must not', + ); + expect(source).toContain('_isAuthorizedSessionNavigation(request.url)'); + // Authorization requires an app-initiated bootstrap. + expect(source).toContain( + 'isOtpBootstrapUrl(url) && Boolean(this.props.destination)', + ); + // The window closes once the destination loads. + expect(source).toContain( + 'pendingDestination: null, webviewUrl: destination', + ); + expect(read('screens/WebViewScreen.js')).toContain( + 'destination={this.props.route.params.destination}', + ); + }); + + test('read-marking still precedes destination resolution', () => { + const handler = read('screens/NotificationsScreen.js'); + const block = handler.slice( + handler.indexOf('_openNotificationForSite('), + handler.indexOf('_listIndex(row)'), + ); + expect(block.indexOf('markNotificationRead')).toBeLessThan( + block.indexOf('endpointForSiteNotification'), + ); + }); +}); diff --git a/js/notificationDestination.js b/js/notificationDestination.js new file mode 100644 index 000000000..0541188c4 --- /dev/null +++ b/js/notificationDestination.js @@ -0,0 +1,31 @@ +/* @flow */ +'use strict'; + +// Presentation decision for a classified member destination. Kept pure and +// separate from Discourse.js so every branch is directly testable. +// +// A 'first_party_web' destination is a valid first-party member page with no +// native screen. It is opened in the in-app WebView, but only after an +// authenticated Discourse session has been bootstrapped - see webViewSession. +// Loading it without one would show a login wall, which is exactly what +// WebViewComponent's navigation policy exists to prevent. +export const WEB_SESSION_UNAVAILABLE = Object.freeze({ + title: 'Not available right now', + message: + 'Adjuster Network could not open this page in the app. It has been marked as read, and nothing else is affected. Try again later.', + close: 'Close', +}); + +export function destinationPresentation(route) { + switch (route?.disposition) { + case 'native': + return { kind: 'native', screen: route.screen, params: route.params }; + case 'first_party_web': + return { kind: 'web', url: route.url }; + case 'privileged_external': + return { kind: 'external', url: route.url }; + default: + // Off-origin, unauthenticated, non-staff /admin, malformed or unknown. + return { kind: 'denied' }; + } +} diff --git a/js/screens/WebViewScreen.js b/js/screens/WebViewScreen.js index f0fb10b7f..ce5f3a9b4 100644 --- a/js/screens/WebViewScreen.js +++ b/js/screens/WebViewScreen.js @@ -14,6 +14,7 @@ class WebViewScreen extends React.Component { ); } diff --git a/js/screens/WebViewScreenComponents/WebViewComponent.js b/js/screens/WebViewScreenComponents/WebViewComponent.js index edb8f2d00..5f0f3e906 100644 --- a/js/screens/WebViewScreenComponents/WebViewComponent.js +++ b/js/screens/WebViewScreenComponents/WebViewComponent.js @@ -25,6 +25,7 @@ import { ThemeContext } from '../../ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurView } from '@react-native-community/blur'; import { classifyNavigation } from '../../adjusterNetworkSecurity'; +import { isOtpBootstrapUrl } from '../../webViewSession'; import { NestedHeader } from '../../product/ProductComponents'; import { classifyFirstPartyMemberRoute } from '../../nativeMemberRouting'; @@ -82,6 +83,9 @@ class WebViewComponent extends React.Component { webviewUrl: this.props.url, authProcessActive: false, scrollOverflow: 0, + // Set only when the app itself initiated an OTP session bootstrap and + // still owes the member the page they actually asked for. + pendingDestination: this.props.destination || null, }; } @@ -290,6 +294,14 @@ class WebViewComponent extends React.Component { this.props.screenProps.openUrl(request.url); return false; } + // A session bootstrap the app itself started is allowed to + // run: the OTP confirmation page, the redirect it performs, + // and finally the destination that was requested. The window + // is closed as soon as the destination loads, so this is not a + // standing "any internal page opens" relaxation. + if (this._isAuthorizedSessionNavigation(request.url)) { + return true; + } // Canonical pages without an explicit native route must not // fall through to an unauthenticated Discourse/PWA session. return false; @@ -319,6 +331,7 @@ class WebViewComponent extends React.Component { }} onNavigationStateChange={navState => { this._storeLastPath(navState); + this._advanceSessionBootstrap(navState); }} decelerationRate={'normal'} onLoadProgress={({ nativeEvent }) => { @@ -465,6 +478,25 @@ class WebViewComponent extends React.Component { } } + // Authorized only while an app-initiated bootstrap is outstanding. + _isAuthorizedSessionNavigation(url) { + if (!this.state.pendingDestination) { + return isOtpBootstrapUrl(url) && Boolean(this.props.destination); + } + return true; + } + + // The confirmation form posts back to /session/otp/ and then Discourse + // redirects. When navigation has left the bootstrap path the session cookie + // exists, so the originally requested page is loaded exactly once and the + // authorization window closes. + _advanceSessionBootstrap(navState) { + const destination = this.state.pendingDestination; + if (!destination || navState.loading) return; + if (isOtpBootstrapUrl(navState.url)) return; + this.setState({ pendingDestination: null, webviewUrl: destination }); + } + _onMessage(event) { let data; try { diff --git a/js/webViewSession.js b/js/webViewSession.js new file mode 100644 index 000000000..42cd4fa46 --- /dev/null +++ b/js/webViewSession.js @@ -0,0 +1,108 @@ +/* @flow */ +'use strict'; + +import { AUTH_REDIRECT } from './authorizationConsent'; +import { isCanonicalUrl } from './adjusterNetworkSecurity'; +import { parseAuthCallbackParameters } from './authCallback'; + +// Bootstrapping an authenticated Discourse browser session for the in-app +// WebView. The WebView carries cookies, not the User API key, so a first-party +// member page cannot be opened until a session cookie exists. +// +// The supported contract: POST /user-api-key/otp with the app's existing User +// API credentials returns a redirect_url carrying an RSA-encrypted one-time +// password. The app decrypts it with the same private key used by the +// authorization flow and loads /session/otp/, where the member completes +// the existing confirmation form. That form - never bypassed - is what sets the +// session cookie. +// +// The OTP is single-use with a 10 minute TTL, and the server refuses User API +// keys for suspended or inactive users, so an unauthorised member cannot reach +// a session this way. +export const OTP_BOOTSTRAP_PATH = '/session/otp/'; +export const OTP_ENDPOINT = '/user-api-key/otp'; + +// Discourse's authentication cookie. Its presence means the WebView already +// holds a logged-in session and no OTP needs to be minted. +const AUTH_COOKIE = '_t'; + +// The route constrains the token to hex, so anything else must never be +// interpolated into the path. +const OTP_TOKEN = /^[0-9a-f]+$/; + +export function otpBootstrapUrl(site, otp) { + if (!site?.url || typeof otp !== 'string' || !OTP_TOKEN.test(otp)) { + return null; + } + return `${site.url}${OTP_BOOTSTRAP_PATH}${otp}`; +} + +export function isOtpBootstrapUrl(value) { + if (!isCanonicalUrl(value)) return false; + try { + return new URL(value).pathname.startsWith(OTP_BOOTSTRAP_PATH); + } catch { + return false; + } +} + +export async function hasAuthenticatedWebSession(site, cookies = null) { + if (!site?.url) return false; + try { + // Required lazily so importing this module never pulls in the native + // cookie package. Suites that merely reach Discourse.js must not have to + // mock it, and nothing else in this module needs it. + const jar = await (cookies || require('@react-native-cookies/cookies')).get( + site.url, + true, + ); + const token = jar?.[AUTH_COOKIE]; + return Boolean(token && token.value); + } catch { + // An unreadable cookie jar is treated as no session: the worst outcome is + // minting one extra single-use OTP. + return false; + } +} + +export async function requestOneTimePassword(site, siteManager) { + if (!site?.authToken) throw new Error('web_session_unauthenticated'); + await siteManager.ensureRSAKeys(); + const publicKey = siteManager.rsaKeys?.public; + if (!publicKey) throw new Error('web_session_key_unavailable'); + + // Reuses site.jsonApi, so the existing User-Api-Key and User-Api-Client-Id + // headers, rate-limit buckets and cooldowns all apply unchanged. + const payload = await site.jsonApi(OTP_ENDPOINT, 'POST', { + public_key: publicKey, + auth_redirect: AUTH_REDIRECT, + padding: 'pkcs1', + }); + + const encrypted = parseAuthCallbackParameters( + payload?.redirect_url, + ).oneTimePassword; + if (!encrypted) throw new Error('web_session_otp_missing'); + + // Same JSEncrypt private key as the authorization flow; no second + // cryptographic implementation. + const otp = siteManager.decryptHelper(encrypted); + if (typeof otp !== 'string' || !OTP_TOKEN.test(otp)) { + throw new Error('web_session_otp_invalid'); + } + return otp; +} + +// Resolves what the WebView should load for a first-party destination: the +// destination directly when a session already exists, otherwise a bootstrap +// that remembers where to go afterwards. +export async function resolveWebSessionEntry(site, siteManager, destination) { + if (!isCanonicalUrl(destination)) throw new Error('web_session_destination'); + if (await hasAuthenticatedWebSession(site)) { + return { url: destination, destination: null }; + } + const otp = await requestOneTimePassword(site, siteManager); + const bootstrap = otpBootstrapUrl(site, otp); + if (!bootstrap) throw new Error('web_session_otp_invalid'); + return { url: bootstrap, destination }; +}