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 };
+}