From 65f9d58ae5a78f09698de8cf98cb6c00628e3fd6 Mon Sep 17 00:00:00 2001 From: Aalv3 <120076079+Aalv3@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:00:14 -0400 Subject: [PATCH] feat(notifications): bootstrap an authenticated WebView session for member pages First-party member pages could not open in the app because the WebView carries cookies, not the User API key, and WebViewComponent's navigation policy correctly refuses to load a canonical page into an unauthenticated Discourse session. PR #16 routed there anyway, which produced a blank screen stuck on "Still loading...". The supported contract closes the gap. webViewSession posts the app's existing RSA public key to /user-api-key/otp with the governed auth_redirect and pkcs1 padding, using site.jsonApi so the existing User-Api-Key and User-Api-Client-Id headers, rate-limit buckets and cooldowns all apply. The returned redirect_url is parsed with the same helper the authorization callback uses and the one-time password is decrypted with the same JSEncrypt private key. No second cryptographic implementation, and no new server endpoint. The WebView then loads /session/otp/, the member completes the existing confirmation form, and only then is the originally requested destination loaded. The confirmation step is never bypassed: it is what sets the session cookie. An existing session is reused rather than spending an OTP: a live Discourse _t cookie means the destination loads directly. The cookie package is required lazily so importing this module does not pull a native dependency into every suite that reaches Discourse.js. The WebView policy relaxation is scoped to a bootstrap the app itself started. Authorization requires a pending destination, and the window closes the moment that destination loads, so this is not a standing "any internal page opens" rule. The original guard is untouched for everything else. Safety holds throughout. The decrypted OTP must match the route's hex constraint before it is interpolated into a path. Off-origin destinations are refused before an OTP is minted. Non-staff /admin, malformed payloads, unknown types and unauthenticated callers remain denied. Any failure or cancellation ends in a bounded explicit state rather than a blank WebView. Native Topic and MemberProfile routing, notification read-marking and the private member-photo credential boundary are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR --- js/Discourse.js | 62 ++- js/__tests__/notificationRouting.test.js | 22 +- js/__tests__/webViewSession.test.js | 380 ++++++++++++++++++ js/notificationDestination.js | 31 ++ js/screens/WebViewScreen.js | 1 + .../WebViewComponent.js | 32 ++ js/webViewSession.js | 108 +++++ 7 files changed, 611 insertions(+), 25 deletions(-) create mode 100644 js/__tests__/webViewSession.test.js create mode 100644 js/notificationDestination.js create mode 100644 js/webViewSession.js 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 }; +}