ADR-010: Backend vs Frontend Handling of the Signals OAuth Implicit Flow
| Field | Value |
|---|---|
| Status | Open |
| Date | 2026-04-08 |
| Related SAD | SAD-001 |
| Related ADR | ADR-002, ADR-003, ADR-006, ADR-008 |
Context
External Actions require a Signals access token to read/write data back to the ELN.
Signals only supports the OAuth implicit grant, which returns the token in the
URL fragment (#access_token=...). URL fragments are never sent to the server —
they are only accessible to JavaScript running in the browser.
The backend needs this token (to store in ElastiCache and use for API calls), but it cannot receive it directly from the redirect. JavaScript in the browser must extract it and relay it to the backend.
The question is: should the callback page that extracts the token be served by the frontend (React) or the backend (FastAPI HTML response)? Both work, but they have different security implications.
Decision
Pending. The dual OAuth flow itself is not in question — Roche identity (Janus, Authorization Code Grant + PKCE) and Signals API token (implicit grant) are both required. The open decision is whether the Signals OAuth callback page is served by the frontend (React) or the backend (FastAPI HTML response).
The flow (Option A: backend-served callback)
The flow (Option B: frontend-served callback)
Redirect URLs and provisioned client IDs
Based on the modular monolith approach (ADR-006), a single application handles all External Actions under one hostname:
| Environment label | Signals tenant | Redirect URL | Client ID |
|---|---|---|---|
| LOCAL | roche-orca-signalsnotebook | http://localhost:3000/auth/signals-callback | client-989c0537-e804-4516-84b7-f2085d3acbf4 |
| LOCAL | roche-dia-dev | http://localhost:3000/auth/signals-callback | client-989c0537-e804-4516-84b7-f2085d3acbf4 |
| xRED DEV | roche-orca-signalsnotebook | https://xred-eln-integration.minerva.sandbox.pcloud.roche.com/auth/signals-callback | client-989c0537-e804-4516-84b7-f2085d3acbf4 |
| LEM DEV | roche-dia-dev | https://xred-eln-integration.minerva.sandbox.pcloud.roche.com/auth/signals-callback | client-989c0537-e804-4516-84b7-f2085d3acbf4 |
| LEM TEST | roche-dia-tst-signalsnotebook | https://xred-eln-integration.apps.uat.minerva.roche.com/auth/signals-callback | client-64bf3274-5eb6-49d9-8139-f66e980318c0 |
| LEM TRAINING | roche-glbtrn | https://xred-eln-integration.apps.uat.minerva.roche.com/auth/signals-callback | client-180fbda6-a5d1-466a-bfb1-32760fa7ae10 |
| LEM PROD | roche-dia-signalsnotebook | https://xred-eln-integration.apps.minerva.roche.com/auth/signals-callback | client-180fbda6-a5d1-466a-bfb1-32760fa7ae10 |
These URLs must be registered exactly with Revvity when requesting the Signals Client ID. Provisioned mapping was validated on 2026-04-22.
Client IDs are provisioned per Revvity cluster and shared across environments in the same cluster:
signalsresearch:client-989c0537-e804-4516-84b7-f2085d3acbf4srpste3:client-64bf3274-5eb6-49d9-8139-f66e980318c0srpe3:client-180fbda6-a5d1-466a-bfb1-32760fa7ae10
Open dependency:
- Canonical OAuth authorization endpoint URLs per tenant are not yet documented as explicit URLs in this repository.
The callback page
The /auth/signals-callback route serves a page (either a React route or a FastAPI HTML
response) with JavaScript that:
- Extracts
access_tokenfromwindow.location.hash - Sends it to the backend via
POST /api/auth/token - Redirects the user to the integration UI once the backend confirms storage
// Callback page JS (simplified)
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const token = params.get('access_token');
fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
})
.then(() => { window.location.href = '/'; });Token storage
Both tokens are stored in ElastiCache (ADR-003):
| Token | Source | TTL | Used for |
|---|---|---|---|
| Roche identity token | Janus | 1 hour (refresh token: 30 days) | Calls to internal Roche systems |
| Signals access token | Signals OAuth | 30 days (invalidated after 30 days of inactivity) | Read/write back to Signals via MuleSoft |
A session cookie links the user’s browser to their token pair in ElastiCache.
Rationale
Backend redirect vs frontend relay
The Signals OAuth implicit grant returns the token in the URL fragment
(#access_token=...). URL fragments are never sent to the server in HTTP requests —
they exist only in the browser. This is a fundamental constraint of the implicit grant
(RFC 6749 §4.2).
This means a pure backend callback is not possible. Two approaches exist:
Option A: Backend serves the callback page (FastAPI HTML response)
FastAPI serves a minimal HTML page on /auth/signals-callback with an inline
<script> that extracts the token and POSTs it to /api/auth/token.
- Minimal attack surface — no dependencies loaded on the callback page
- No frontend routing or build changes needed
- Backend-only actions (e.g. refresh triggers) don’t require frontend deployment
Option B: Frontend serves the callback page (React route)
A React route handles /auth/signals-callback — a component extracts the token,
shows a loading spinner, and POSTs it to /api/auth/token.
- Better UX — branded spinners, progress indicators, error states
- Consistent styling with the rest of the application
- Full React bundle loaded during token extraction (larger attack surface)
Both options require JavaScript in the browser to extract the fragment. However, the security properties differ:
| Concern | Frontend callback (React) | Backend callback (FastAPI HTML) |
|---|---|---|
| Attack surface | Full React bundle loaded on callback page — all dependencies, polyfills, third-party code present | Minimal HTML page with a single inline script — no dependencies loaded |
| XSS exposure | If any React dependency has an XSS vulnerability, the token is exposed | Isolated page with no dependencies — much smaller XSS surface |
| CSP control | Harder to lock down CSP when the full app bundle is loaded | Can set a strict CSP (no external scripts, no inline except the extraction) |
| Token lifetime in memory | Token exists in React’s JS context alongside the full app state | Token exists only in the inline script, page navigates away immediately |
| Third-party scripts | Any analytics, error tracking, or A/B testing loaded by React could see the fragment | No third-party scripts loaded |
| Build complexity | Callback is part of the frontend build — changes to the app could affect it | Callback is a standalone HTML string in FastAPI — decoupled from frontend |
| User experience | Full React — can show branded spinners, progress indicators, error states with consistent app styling | Bare HTML — user sees a brief flash of a minimal page before redirect |
| Backend-only actions | Need to deploy frontend changes even if the action has no UI (e.g. a refresh or sync trigger) | Just add a backend endpoint — no frontend deployment needed |
The trade-off is security vs UX: the backend option minimises what runs in the browser during token extraction (smaller attack surface), while the frontend option provides a polished user experience with consistent app styling.
Two separate OAuth flows are required
Janus authenticates the user’s Roche identity. The Signals API requires a Signals-issued token — a separate credential that authorises API access to the specific Signals tenant. These are two different identity systems that cannot be substituted. Both tokens are needed for External Actions that read from internal systems (Roche token) and write back to Signals (Signals token).
Security Considerations
Token exposure in the browser
The Signals access token is visible in the browser’s URL bar (in the fragment) and in JavaScript memory during the callback. This is inherent to the implicit grant — there is no way to avoid it. Mitigations:
- The callback page should immediately extract the token and clear the fragment
(
window.location.hash = '') to minimise the exposure window - The callback page should have no third-party scripts, analytics, or external resources that could intercept the fragment
Token in transit (browser → backend)
The token travels from the browser to the backend in a POST /api/auth/token request
body. This is over HTTPS (TLS), so it is encrypted in transit. However:
- The token is in plaintext in the request body — if the backend logs request bodies,
the token would appear in logs. The
/api/auth/tokenendpoint must not log the request body. - CSRF protection is needed on this endpoint — without it, a malicious page could POST
a forged token. Use a CSRF token or verify the
Origin/Refererheader.
Token storage (ElastiCache)
The Signals token is stored in ElastiCache alongside the Roche identity token. Both are sensitive credentials. See ADR-003 and ADR-009 for the shared Redis concerns (memory pressure, security boundary).
- ElastiCache encryption-at-rest and encryption-in-transit must be enabled
- Tokens should have a TTL matching the session duration
- The session cookie linking the browser to the token pair must be
Secure,HttpOnly, andSameSite=Lax(orStrict)
Implicit grant limitations
The implicit grant is considered less secure than the authorization code grant (RFC 6749 §10.16):
- No refresh tokens — if the token expires (30 days of inactivity), the user must re-authorise
- Token exposed in URL fragment — visible in browser history and potentially in Referer headers (mitigated by immediate extraction + fragment clearing)
- No client authentication — the Client ID is public, anyone can initiate the flow (the redirect URI whitelist is the only protection)
These are inherent to Signals only supporting the implicit grant. For the Roche identity leg (Janus), we use the more secure Authorization Code Grant + PKCE.
XSS risk
If the application is vulnerable to XSS, an attacker could inject JavaScript that reads the token from the fragment before the callback page processes it, or intercept the POST to the backend. Standard XSS mitigations apply:
- Content Security Policy (CSP) headers
- No inline scripts on the callback page beyond the token extraction
- Input sanitisation on all user-facing content
Consequences
- The callback page is a thin relay — minimal code, but it must be maintained and kept free of third-party scripts
- The
/api/auth/tokenendpoint must not log request bodies - CSRF protection required on the token relay endpoint
- Session cookies must be
Secure,HttpOnly,SameSite - The implicit grant does not support refresh tokens — token expiry requires re-authorisation
- Redirect handling remains centralised in one callback path (
/auth/signals-callback) with three provisioned Client IDs (one per Revvity cluster), shared across environments in each cluster - Adding a new integration does not require a per-integration Client ID or redirect URI