How to Enable BFF Authentication¶
This guide covers enabling the Backend-for-Frontend (BFF) authentication pattern in SyRF, with step-by-step instructions for both Auth0 and OpenIddict as the identity provider.
Background¶
The BFF pattern moves all OIDC handling to the server side. Angular uses cookies and /api/auth/* endpoints — it has no client-side auth library (no Auth0 SDK, no OIDC client). The API service acts as a confidential OIDC client, handling authorization code flow with PKCE, token exchange, and session management.
┌─────────────┐ cookie ┌─────────────┐ OIDC ┌─────────────────┐
│ Angular │ ────────► │ API (BFF) │ ───────► │ Auth0 │
│ (SPA) │ │ /api/auth │ │ or │
│ │ ◄──cookie─ │ /api/* │ │ OpenIddict │
└─────────────┘ └─────────────┘ └─────────────────┘
The BFF controller uses standard OIDC — discovery document, authorization endpoint, token endpoint, userinfo endpoint. The identity provider is purely a server-side configuration choice. Angular is identical regardless of which IdP is used.
Prerequisites¶
- PR #2416 merged (BFF auth infrastructure)
- Access to the target environment's Helm values (cluster-gitops)
- For Auth0: Admin access to the Auth0
syrftenant - For OpenIddict: Identity service deployed and configured
Option A: BFF with Auth0¶
Use this when you want to test the BFF pattern without changing the identity provider. Auth0 continues to manage users, passwords, and Google OAuth. Only the frontend auth mechanism changes (JWT tokens → cookies).
Step 1: Create an Auth0 Confidential Client¶
The current Auth0 application (UYpAGmQq1leH...) is a Single Page Application (public client) — it cannot hold a client secret. The BFF requires a confidential client because the API sends client_secret in the token exchange.
- Log in to Auth0 Dashboard → tenant
syrf - Go to Applications → Create Application
-
Settings: Name =
SyRF BFF(or similar), Application Type =Regular Web Application -
In the new application's Settings tab, configure:
Allowed Callback URLs (one per environment):
https://api.staging.syrf.org.uk/api/auth/callback,
https://api.syrf.org.uk/api/auth/callback,
https://api.pr-2416.syrf.org.uk/api/auth/callback
Allowed Logout URLs:
Allowed Web Origins: same as logout URLs above.
Grant Types: Ensure Authorization Code is enabled.
Token Endpoint Authentication Method: Post (sends client_secret in body, matching the BFF implementation).
- Note the Client ID and Client Secret
Important: The BFF sends
client_secretin the token request body (line 201 of BffAuthController.cs), not as a Basic Auth header. Ensure "Token Endpoint Authentication Method" is set toPost, notBasic.
Step 2: Configure Auth0 Custom Claims¶
Auth0 needs to include SyRF-specific claims in the userinfo response. If you haven't already, ensure the following Auth0 Action (or Rule) is configured:
Post-Login Action — adds custom claims to the access token and userinfo:
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://claims.syrf.org.uk/';
// Map Auth0 user metadata to SyRF claims
api.accessToken.setCustomClaim(namespace + 'user_id', event.user.user_id);
api.idToken.setCustomClaim(namespace + 'user_id', event.user.user_id);
if (event.user.given_name) {
api.accessToken.setCustomClaim(namespace + 'given_name', event.user.given_name);
}
if (event.user.family_name) {
api.accessToken.setCustomClaim(namespace + 'family_name', event.user.family_name);
}
if (event.user.nickname) {
api.accessToken.setCustomClaim(namespace + 'preferred_name', event.user.nickname);
}
if (event.user.picture) {
api.accessToken.setCustomClaim(namespace + 'picture', event.user.picture);
}
};
The BFF controller reads these claims from the userinfo endpoint (see AuthConstants.ClaimTypes in Constants.cs).
Step 3: Create Kubernetes Secrets¶
Create the bff-auth secret in the target namespace with the confidential client credentials from Step 1.
For preview/staging environments, add the secret via GCP Secret Manager + External Secrets Operator, or create directly:
# Example for staging (adapt namespace as needed)
# In practice, use GCP Secret Manager → ExternalSecret
kubectl create secret generic bff-auth \
--namespace=syrf-staging \
--from-literal=clientSecret='YOUR_AUTH0_CLIENT_SECRET'
For session storage, do not create a Secret by hand. Staging, production and every PR preview already
receive a GitOps-managed bff-redis Secret (key connectionString) for their Valkey ACL user; see
BFF session store (Valkey).
Note: Outside Development, Local and E2E, the API refuses to start with BFF enabled unless both
bffAuth.redis.secretNameandbffAuth.redis.keyPrefixare set. The in-memory store is for development and tests only.
Step 4: Update API Helm Values (cluster-gitops)¶
In cluster-gitops/syrf/environments/{env}/api/values.yaml:
# Enable BFF authentication
bffAuth:
enabled: true
# authority: auto-derived from auth0.customDomain — no need to set explicitly
clientId: "YOUR_AUTH0_BFF_CLIENT_ID" # From Step 1
clientSecretName: "bff-auth" # K8s secret from Step 3
# Redis session store (required outside Development). Already present in
# staging/production values; previews get it from the preview ApplicationSet.
redis:
secretName: "bff-redis"
keyPrefix: "staging:" # this environment's Valkey ACL prefix
Step 5: Web Configuration (automatic)¶
The web frontend's authProvider is auto-derived from bffAuth.enabled. When BFF is enabled on the API, the web automatically uses authProvider: "bff". No separate web configuration is needed.
Override: If you need to force a specific auth provider on the web regardless of BFF state, set
authProviderexplicitly in the web Helm values.
Step 6: Commit, Push, and Verify¶
cd cluster-gitops
git add .
git commit -m "feat(auth): enable BFF auth with Auth0 for {environment}"
git push
ArgoCD will sync the changes. Verify:
- API pod restarts with new env vars (check
SYRF__BffAuth__Enabled=true) - Web pod restarts with
authProvider=bffin config - Login flow: Navigate to the app → redirects to
/api/auth/login→ Auth0 Universal Login → callback → cookie set → app loads - Session check:
GET /api/auth/mereturns user profile - API calls: Existing functionality works with cookie auth
Auth0 provider behaviour¶
These apply whenever BffAuth:Provider is auth0: staging's S09 Task 3 option B rehearsal (passed 2026-10-07; staging has since been reapplied to OpenIddict by M005 S27, so only the rehearsal and the retained rollback path use it) and the
production M005 S11/S13 shape.
- Logout ends the Auth0 session. Auth0's discovery document returns
end_session_endpoint: nullunless the tenant-wide "RP-Initiated Logout End Session Endpoint Discovery" setting is on, and the SyRF tenant is shared with production, so the setting is left alone.POST /api/auth/logouttherefore builds Auth0's OIDC logout URL itself:{authority}/oidc/logout?post_logout_redirect_uri={app origin}&client_id={BFF client}&id_token_hint={id_token}. The ID token comes from the server-side session (it never reaches a cookie),client_idmakes Auth0 check the BFF application's Allowed Logout URLs, and the hint stops Auth0 from showing its logout confirmation prompt. The app origin (https://staging.syrf.org.ukfor staging,FrontendOriginwhen set) must be in that application's Allowed Logout URLs, or Auth0 shows an error page instead of returning to the app. When discovery does advertise an end-session endpoint it is used, with the same parameters. OpenIddict logout URLs are unchanged. - Action "email sent" pages end the Auth0 session.
check-email-verificationandupgrade-social-to-auth0-user(docs/auth0-actions/) pause an unverified or upgrading user's sign-in on/auth/confirmation-email-sentor/auth/welcome-email-sent. The user now has an Auth0 session but usually no BFF session. The direct Auth0 SPA ends that session there (silentSignOut); without the same in BFF mode, every later silent check would re-run the Actions (another confirmation email each time), and on a shared computer the next person to click Sign in would continue as that user. In BFF mode the pages: - learn the provider from
/api/auth/me, which reportsprovider(auth0,openiddictormock, the configuredBffAuth:Provider) in both its 200 and its 401 body. Any provider butauth0does nothing; OpenIddict never sends users to these pages; - set
syrf-signed-out(see Sign-out semantics), so no silent attempt re-runs the Actions, and callPOST /api/auth/logout, which ends any BFF session; - load the response's
providerLogoutUrlin a hidden iframe, once per page load. The page never navigates, so it keeps showing the address the email went to.
providerLogoutUrl is {authority}/v2/logout?client_id={BFF client}&returnTo={app origin}, returned for
provider auth0 with or without a session (null otherwise), and carries no token. It is Auth0's legacy
logout endpoint, not /oidc/logout, because there is no ID token to send: Auth0 shows its logout consent
prompt when a /oidc/logout request has neither id_token_hint nor logout_hint
(Auth0: logout consent prompt),
the SyRF tenant keeps that prompt on, and a prompt hidden in an iframe would never end the session.
/v2/logout has no prompt, and the direct Auth0 SPA already loads it on these pages. client_id makes Auth0
check returnTo against the BFF application's Allowed Logout URLs, which hold only the app origin (checked
on staging 2026-10-07: the origin is accepted, a deeper path such as /auth/confirmation-email-sent is
refused with 400), so the hidden frame returns to the app root and is removed once it has loaded (10 seconds
at most). That frame starts no silent sign-in, because syrf-signed-out is already set. The Auth0 session
cookie reaches the frame because signin.syrf.org.uk and the app are same-site. No Auth0 tenant setting
changes.
Not flagged: it brings the BFF Auth0 path into line with the direct Auth0 behaviour, applies only to these two
pages and only to provider auth0 (ordinary staging ran it only during the S09 rehearsal, until M005 S27 reapplied OpenIddict on 2026-10-07; no environment runs it now), and changes
nothing for OpenIddict or the direct Auth0 SPA.
- Names. The Auth0 Action sets the namespaced preferred_name on the access token only, so the BFF falls
back to the standard nickname from /userinfo, the same value the direct-JWT path receives. When
/userinfo has neither given_name nor family_name, the sign-in passes no name and the investigator's
stored first, last and preferred names are kept (a brand-new investigator is named from the email). The
Action sets user_id and syrf_groups on the ID token, and Auth0 returns ID-token custom claims from
/userinfo; without user_id, resolution falls back to the investigator whose stored Auth0 ID equals
sub, and fails closed otherwise. OpenIddict name mapping is unchanged.
- Refused sign-ins. An Action denial returns to /api/auth/callback?error=access_denied&error_description=…
with no code. The callback creates no session and redirects to the app root with auth_error (an allow-listed
OAuth error code, otherwise sign_in_failed) and, only when this browser started the flow (PKCE cookie and
state nonce match), auth_error_description: plain text, markup and control characters removed, at most 300
characters. The web BFF provider emits them on error$ and removes them from the URL, so the user sees the
same snackbar as on the direct Auth0 SPA path; login_required stays silent. A verified silent attempt
(prompt=none, see Silent sign-in) is handled before this path. The PKCE
cookie is consumed only when the state nonce matches, so a crafted ?error= link cannot cancel a sign-in in
progress. It goes to the app root, not
the original return URL, because a protected return URL would start another sign-in at once.
- Action app links (callback subpaths). SyRF's Auth0 post-login Actions (docs/auth0-actions/) build
some app links by appending a path to the transaction's redirect_uri:
check-email-verification and upgrade-social-to-auth0-user send the browser to
{redirect_uri}/auth/confirmation-email-sent or {redirect_uri}/auth/welcome-email-sent (with email,
name and Auth0's state). For the direct SPA redirect_uri was the app origin; through the BFF it is
/api/auth/callback, so the browser arrives at /api/auth/callback/auth/confirmation-email-sent?….
GET /api/auth/callback/{appPath} redirects (302) to /{appPath} with the query string unchanged, so the
page still has state for Auth0's /continue. Only a plain relative app path is forwarded: slash-separated,
non-empty segments of ASCII letters, digits and -._~, no ./.. segment, not under api/, at most 256
characters; anything else (//, backslash, percent-encoding, whitespace, control characters) is 404. It
touches no cookie or session, and /api/auth/callback itself is unchanged. request-extra-profile-info
(/auth/complete-profile-info) and the external-account redirect build their links from the origin, so they
never needed this. No Auth0 change is required. The SPA starts no silent sign-in on these Action pages (see
Loop guard).
- Live smoke. scripts/auth-migration/live-smoke.sh --expected-provider auth0 runs the BFF journey against
Universal Login (identifier-first). It does not run the emailed reset row (Auth0 mails through its own
provider and a reset would change a credential in the shared tenant) or the admin row (administrator groups
live in the shared tenant); the evidence records both as false with a reason under not_run. Logout must
return a complete /oidc/logout URL and the following sign-in must show the credential form again. Set
AUTH_SMOKE_REFRESH_TOKENS_NOT_ISSUED=true only if the BFF application gets no refresh grant; the evidence
then records refresh_logout: false with a reason. A bot-detection challenge fails the run with an
"operator needed" message rather than a timeout.
Rollback¶
To revert to direct Auth0 SDK auth:
1. Set bffAuth.enabled: false in API values (web auto-reverts to auth0)
2. Commit and push to cluster-gitops
The combined auth scheme still accepts JWT bearer tokens, so existing Auth0 SDK sessions continue to work during transition.
Rehearsed variant (M005 S09 option B): the rollback that staging rehearsed, and that production S13 would use
if the OpenIddict cutover fails, keeps the BFF enabled and only swaps the provider from OpenIddict back to Auth0.
Change BffAuth.Provider, authority, client reference and session generation together (a fresh generation, never a
reused one), and set identityProvider: auth0 (as staging did in cluster-gitops#1530). Cookies from the OpenIddict generation are then refused
(401) because the generation changed. Passed on staging on 2026-10-07; the BFF's Auth0 application needs its own
secret and Allowed Logout URLs (see Auth0 provider behaviour).
Option B: BFF with OpenIddict¶
Use this after verifying BFF works with Auth0. The only change is the authority URL and client credentials — Angular is identical.
Step 1: Deploy the Identity Service¶
Ensure the identity service is deployed and healthy in the target environment:
- Helm chart:
src/services/identity/.chart/— deployed via ArgoCD - Secrets required by the identity service:
mongodb-identity— MongoDB connection string for identity datagoogle-oauth— Google OAuth client ID and secret (reused from Auth0)ses-credentials— AWS SES credentials for email deliveryopeniddict-signing-key— JWT signing keyopeniddict-encryption-key— JWT encryption key- DNS:
identity.{env}.syrf.org.ukresolving to the ingress
Step 2: Register the API as an OpenIddict Client¶
The identity service needs a registered client for the BFF. This is done via the OpenIddictClientSeeder which runs on startup:
In the identity service's Helm values or appsettings:
{
"OpenIddict": {
"Clients": [
{
"ClientId": "syrf-web",
"ClientSecret": "GENERATE_A_SECURE_SECRET",
"DisplayName": "SyRF Web Application (BFF)",
"RedirectUris": [
"https://api.staging.syrf.org.uk/api/auth/callback"
],
"PostLogoutRedirectUris": [
"https://staging.syrf.org.uk"
],
"Permissions": [
"ept:authorization",
"ept:token",
"ept:logout",
"gt:authorization_code",
"gt:refresh_token",
"scp:openid",
"scp:profile",
"scp:email",
"scp:offline_access",
"scp:syrf_api"
]
}
]
}
}
Step 3: Declare Kubernetes Secret References¶
Use the established cluster-gitops ExternalSecret/generator resources to
create openiddict-web-client and openiddict-api-client in the target
namespace. Identity and API must reference the same generated API credential;
no value belongs in Git, Helm values, CI output, or a manual kubectl create
command. Review the names and keys in the environment diff before ArgoCD sync.
Step 4: Update API Helm Values¶
In cluster-gitops/syrf/environments/{env}/api/values.yaml:
# BFF pointing to OpenIddict
bffAuth:
enabled: true
# authority: auto-derived from identityService.baseUrl — no need to set explicitly
clientId: "syrf-web"
clientSecretName: "bff-auth"
# Identity service admin API (server-to-server user management)
identityService:
baseUrl: "https://identity.staging.syrf.org.uk"
audience: "https://api.staging.syrf.org.uk"
clientId: "syrf-api"
clientSecretName: "openiddict-api-client"
Step 5: Web Configuration (automatic)¶
Same as Auth0 — the web's authProvider is auto-derived from bffAuth.enabled. No separate configuration needed.
Step 6: Verify¶
Same verification steps as Auth0, plus:
- Identity process liveness: GET https://identity.{env}.syrf.org.uk/health/live (200 once the process is up)
- Identity dependency readiness: GET https://identity.{env}.syrf.org.uk/health/ready (503 until Mongo, OpenIddict client seeding, signing/encryption key material, DataProtection and SES configuration are all ready; body reports per-dependency status only, no detail)
- Discovery document: GET https://identity.{env}.syrf.org.uk/.well-known/openid-configuration
- User registration: Test signup flow through the identity service's pages
- Password reset: Test the email flow via SES
Switching Between Auth0 and OpenIddict¶
The switch requires changing only the identity service URL and client credentials. Authority and authProvider are auto-derived:
| Setting | Auth0 | OpenIddict |
|---|---|---|
bffAuth.enabled |
true |
true |
bffAuth.clientId |
Auth0 BFF client ID | syrf-web |
bffAuth.clientSecretName |
Secret with Auth0 client secret | Secret with OpenIddict client secret |
identityService.baseUrl |
(empty — not needed) | https://identity.{env}.syrf.org.uk |
Auto-derived values (no manual config needed):
| Value | Auth0 | OpenIddict |
|---|---|---|
bffAuth.authority |
from auth0.customDomain |
from identityService.baseUrl |
Web authProvider |
"bff" (from bffAuth.enabled) |
"bff" (from bffAuth.enabled) |
Angular is identical in both cases — no frontend changes needed.
Sign-in flow cookies (per-flow PKCE)¶
Each sign-in flow keeps its PKCE verifier, its state nonce and, for a silent attempt, the silent and frame
flags in its own temporary cookie, named {PkceCookieName}.{nonce} (default syrf-pkce.<32 hex>):
HttpOnly, SameSite=Lax, Secure as configured, Path=/api/auth, 10 minutes. The nonce is the one in
the flow's state, so the callback reads, and consumes, only the cookie of the flow it is completing.
Before this, one shared syrf-pkce cookie held the verifier, so two flows in one browser overwrote each
other. Observed on staging during the S09 Task 3 option B rehearsal: a Sign-in click started flow A, the
still-loading page started its hidden-frame silent check (flow B) 0.2 s later and replaced the cookie, Auth0
refused B with login_required, the callback consumed the cookie, and A's callback then failed with
pkce_missing. Any overlap could do it: a silent check and a Sign-in click, or two tabs signing in.
- Overlapping flows complete independently. A provider error, including a silent refusal, consumes only the cookie of the flow its state names, and only when the nonce matches. A response whose state names no flow of this browser touches nothing (the #4098 rule).
- Cap. Starting a flow keeps at most five flow cookies, dropping the oldest (by the issue time recorded in the cookie), so abandoned flows cannot grow the request header.
- Transition. A flow started before the deploy still holds the old single
{PkceCookieName}cookie. When a callback finds no per-flow cookie, it falls back to that cookie, with its previous nonce rules; a legacy cookie whose nonce does not match is refused and left in place. Login never writes it any more. The fallback can be removed once the release has been live everywhere for longer than the 10-minute PKCE lifetime (follow-up). - E2E stacks set
BffAuth:PkceCookieNametosyrf-pkce-$E2E_STACK; the per-flow cookies becomesyrf-pkce-$E2E_STACK.<nonce>, so stacks stay isolated.
This is a bug fix on the BFF path and is not flagged: it changes no behaviour a single flow can observe, and the legacy-cookie fallback covers sign-ins in flight across the deploy.
Silent sign-in (bffSilentSignIn)¶
The Auth0 SPA SDK restores a session silently when the user still has an Auth0
session. Without help, the BFF does not: once the BFF session ends — the 8-hour
SessionLifetime (unless sliding sessions are
on), a refresh-token revocation (for example after linking a Google
account), or a new browser tab after the session cookie expired — the SPA shows the
signed-out state and the user must click Sign in, even though the sign-in
service would sign them straight back in. Silent sign-in closes that gap.
How it works¶
- On app start, when
GET /api/auth/meanswers 401, the SPA makes a top-level redirect to/api/auth/login?prompt=none&returnUrl=<current path and query>. The SPA keeps its loading state, so the route guard never starts an interactive sign-in while this is in flight. - The API forwards
prompt=noneto the identity provider (OpenIddict or Auth0).noneis the only acceptedpromptvalue; any other value is not forwarded. The silent marker is stored in the server-set, HttpOnly PKCE cookie, never instate. If the provider's discovery document is unavailable, a silent attempt returns to the app signed out (with the failure marker) instead of showing the 502 error page that an interactive sign-in gets. - With a live sign-in-service session the provider returns a code and the
callback creates a BFF session as usual. Without one it returns
login_required,interaction_required,consent_requiredoraccount_selection_required. For a silent attempt (silent PKCE cookie and matching nonce) the callback clears the PKCE cookie, sets thesyrf-silent-auth=failedmarker cookie and redirects to the validated, localreturnUrl. No session is created and nothing sensitive is logged. Any other error response, including one of these codes that is not a verified silent attempt, takes the generic refused-sign-in path described under Auth0 provider behaviour. - Mid-session: when an API call returns 401 and
POST /api/auth/refreshis also refused with 401 (the BFF session has ended), the HTTP interceptor asks the provider for the same silent redirect, preserving the current route. The original 401 still reaches the caller. A tab that was never signed in is not redirected.
Identity's authorization endpoint answers prompt=none without a session with
login_required. Before this change it showed the sign-in page instead, which
would have turned every silent attempt by a signed-out visitor into an
interactive sign-in.
Loop guard¶
Silent sign-in can never loop:
| Guard | Where | Effect |
|---|---|---|
| Per-tab attempt timestamp | sessionStorage key syrf.bff.silentSignInAttemptedAt |
At most one attempt per tab every 5 minutes. If storage cannot be read or written, no attempt is made. |
| Failure marker | syrf-silent-auth=failed cookie (Path=/, SameSite=Lax, Secure as configured, 2 minutes, readable by script) |
Set when the provider refuses. The SPA consumes it and does not retry. It is a cookie rather than a URL marker, so it never appears in addresses, history, Referer headers or access logs. |
| Callback | The SPA never runs on /api/auth/callback |
A completed or refused attempt lands on the original route with a session or with the failure marker. |
| Interactive sign-in | Web BFF provider | Once the tab has started an interactive sign-in, no silent attempt starts, and a frame attempt that cannot complete does not fall back to the top-level silent redirect. |
| Auth0 Action pages | Web BFF provider | No attempt on /auth/confirmation-email-sent, /auth/welcome-email-sent, /auth/complete-profile-info or /auth/external-account: a prompt=none request would re-run Auth0's post-login Actions (which can send another confirmation email) and cannot finish the paused sign-in. |
Sign-out semantics¶
An explicit sign-out keeps the user signed out for the rest of the browser session, whether or not the identity provider's session ends. What happens to the provider's own session depends on the provider:
- OpenIddict:
POST /api/auth/logoutreturns the Identity end-session URL (endSessionUrl), and the browser follows it, so the Identity session ends. - Auth0: the discovery document has no
end_session_endpoint(the tenant's discovery setting stays off, because the tenant is shared with production). The BFF therefore builds{authority}/oidc/logoutitself, withclient_idand anid_token_hinttaken from the server-side session, and the Auth0 session ends there. Universal Login is shown at the next sign-in (proven on staging on 2026-10-07). - Auth0 "email sent" Action pages (
/auth/confirmation-email-sentand/auth/welcome-email-sent): the user has an Auth0 session but usually no BFF session, so the page ends it with{authority}/v2/logoutin a hidden iframe (providerLogoutUrl, no token needed). Details: Auth0 provider behaviour.
Either way the signed-out marker below stops silent sign-in, so a surviving provider session can no longer sign the user back in on its own.
POST /api/auth/logoutsets the browser-session cookiesyrf-signed-out=1(Path=/,SameSite=Lax,Secureas configured, no expiry). The SPA sets the same cookie before it sends the request, so a failed or cross-origin logout is covered too.- While the cookie is present, the API answers
login?prompt=nonewith the failure redirect and never contacts the provider. The SPA also skips the attempt. Every tab in the browser is covered, not just the one that signed out. - An interactive sign-in (
/api/auth/loginwithoutprompt, or/api/auth/signup) clears the cookie in the API and in the SPA.
The flag¶
bffSilentSignIn is a catalogued, runtime-manageable Boolean flag (category
uiFeatures) and is off by default. It is read in two places:
- Web: whether to attempt. The startup check waits up to 4 seconds for the first runtime flag snapshot, so a non-production override applies on first load.
- API: whether
login?prompt=nonereaches the provider. With the flag off, or without a runtime flag provider, aprompt=nonerequest returns the failure redirect instead of becoming an interactive sign-in.
It is never attempted in local development or E2E environments
(runtimeEnvironment local/development, or one containing e2e), nor for
the non-BFF auth0/oidc providers. To enable it in an environment, set
featureFlags.bffSilentSignIn: true in cluster-gitops for both the API and the web
service. The values files the cluster-gitops PR changes are listed in the PR that
added the flag.
Hidden-frame mode (bffSilentSignInFrame)¶
The top-level redirect works, but the user sees it: the app renders, the page
navigates to the sign-in service and back, and the app renders again. Hidden-frame
mode runs the same prompt=none attempt inside an invisible iframe, so the page
never navigates. It is the pattern the Auth0 SPA SDK used (prompt=none in a hidden
iframe), adapted to the BFF: the frame ends on a same-origin BFF page instead of
receiving tokens.
- Startup. When
GET /api/auth/meanswers 401 and every guard above allows an attempt, the SPA stays in its loading state (no signed-out shell, no route-guard sign-in) and loads/api/auth/login?prompt=none&mode=framein a hidden iframe. The attempt is recorded in the same per-tabsessionStorageguard. - API.
mode=frameis honoured only together withprompt=none(anything else is ignored) and is recorded, like the silent flag, only in the server-set HttpOnly PKCE cookie — the provider andstatenever see it, and nothing in the callback request can claim a frame attempt. Every outcome of a frame attempt — success, an "interaction needed" refusal, the flag off, the sign-out marker, discovery unavailable — redirects to/api/auth/silent-completeinstead ofreturnUrl. A refusal still sets thesyrf-silent-auth=failedmarker. /api/auth/silent-completeis a tiny static HTML page:Cache-Control: no-store,Content-Security-Policy: default-src 'none'; script-src 'sha256-…'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'(the single inline script is allowed by its hash),X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer. It posts{ type: 'syrf-silent-auth', ok }towindow.parentwith its own origin as the target,okbeing whether the request carried a valid BFF session — the same boolean/mealready exposes to that origin. It contains no profile data, token or identifier. Opened as a top-level page (Sec-Fetch-Dest: document) it redirects to/.- SPA. The parent accepts a message only from its own origin, only from that
iframe's window, and only with exactly that shape.
ok: true→ it calls/meagain and the app finishes loading signed in.ok: false→ it finishes loading signed out. No message within 6 seconds, or none within 1 second of the frame'sloadevent (a blocked frame loads the browser's error page) → the iframe is removed and the same claimed attempt continues as today's top-level redirect, unless an explicit sign-out or a refusal marker appeared meanwhile, in which case the tab settles signed out. - Mid-session. When an API call gets 401 and
POST /api/auth/refreshis also refused with 401, the interceptor asks the provider to restore the session in a frame (one frame shared by every concurrent 401). If it comes back, the original request is retried and the caller never sees the 401. If/methen reports a different account, the SPA reloads the page rather than swapping identities under running state. If the sign-in service refuses, the original 401 surfaces as before.
Frame mode needs the API on the app's own origin (the production and staging
topology): with a split-origin apiOrigin the SPA uses the top-level redirect.
Browser behaviour¶
The app and both sign-in services are same-site: staging
staging.syrf.org.uk with identity.staging.syrf.org.uk, production
syrf.org.uk/app.syrf.org.uk with Auth0's custom domain signin.syrf.org.uk —
all under the registrable domain syrf.org.uk. A SameSite=Lax cookie is sent on a
subframe navigation when the frame's whole ancestor chain is same-site with the
target, so the provider's session cookie (Identity's .AspNetCore.Identity.Application
is Lax; Auth0's auth0 session cookie is SameSite=None; Secure) reaches the provider,
and the BFF's own Lax PKCE and session cookies are set and sent inside the frame
because it is same-origin with the app.
- Chrome/Edge: third-party-cookie restrictions apply to cross-site frames only. Same-site frames keep first-party cookie access.
- Firefox (Total Cookie Protection, including strict ETP): cookies are partitioned by top-level site; a frame whose site equals the top-level site uses the unpartitioned first-party jar, so the provider session is visible.
- Safari (ITP, full third-party cookie blocking): blocking applies to cross-site frames; same-site subdomains are first party.
- Framing headers: both providers answer
prompt=nonewith a plain302that carries neitherX-Frame-Optionsnor a CSP (checked 2026-10-07 with an unauthenticated request: Identity staging302to the callback witherror=login_required; Auth0signin.syrf.org.uk/authorize?prompt=none302to the redirect URI witherror=login_required), and browsers do not applyX-Frame-Optionsto redirect responses. The page the frame finally renders is the BFF's ownsilent-complete. Auth0's Universal Login pages do refuse framing, but aprompt=nonerequest never reaches them.
If any browser refuses anyway (a privacy extension, an enterprise policy, a future rule change), the frame never posts and the attempt falls back to the top-level redirect after at most 6 seconds — never to an interactive sign-in. Auth0 needs no tenant change for this; none was made.
Identity: frame-ancestors for prompt=none only (identitySilentSignInFrame)¶
Identity's prompt=none answer is a redirect, so frame mode works without any
Identity change. The deployment-only Identity flag identitySilentSignInFrame
(default off; SYRF__FeatureFlags__IdentitySilentSignInFrame) additionally pins who
may frame those responses: with it on, exactly the responses to
GET /connect/authorize whose single prompt value is none carry
Content-Security-Policy: frame-ancestors <origin of AppSettingsConfig:UiUrl>, and any
X-Frame-Options on them is dropped. The origin comes only from configuration,
never from the request.
- Clickjacking: a
prompt=nonerequest never renders interactive UI — Identity redirects with a code orlogin_required/interaction_required, and OpenIddict renders only a static error for a request it cannot redirect — so there is nothing to click. Today/connect/authorizeresponses carry no anti-framing header at all, so the flag restricts framing of these responses to the web app's origin. - Every other response is unchanged, including the sign-in pages'
X-Frame-Options: SAMEORIGIN(sent by the antiforgery system). Tests pin this for the sign-in page, other prompts,POST, other paths and a request that claims a different origin.
The flags¶
| Flag | Where | Default | Effect |
|---|---|---|---|
bffSilentSignIn |
web + API, runtime-manageable (uiFeatures) |
off | Whether a silent attempt happens at all (above). |
bffSilentSignInFrame |
web, runtime-manageable (uiFeatures; depends on bffSilentSignIn) |
off | Whether the browser runs the attempt in a hidden iframe. The API honours mode=frame whenever it honours prompt=none, so this one switch is the kill switch. |
identitySilentSignInFrame |
Identity, deployment-only | off | frame-ancestors on prompt=none authorization responses (optional, see above). |
To enable the frame in staging (cluster-gitops), add to both
syrf/environments/staging/web/values.yaml and
syrf/environments/staging/api/values.yaml:
featureFlags:
bffSilentSignIn: true # already on in staging through a runtime override
bffSilentSignInFrame: true
and, optionally, to syrf/environments/staging/identity/values.yaml:
A non-production administrator can toggle bffSilentSignInFrame from the Feature
flags admin page instead of a deploy.
Sliding sessions (bffSlidingSession)¶
Without it a BFF session lasts exactly SessionLifetime (8 hours) from sign-in, even
for someone working all day, after which silent sign-in has to restore it. With the
runtime flag bffSlidingSession (category backend, API only, default off) the
session slides:
- A request whose session authenticates (and, for native Identity sessions,
passes introspection) extends it to now +
SessionLifetime, at most once perSessionRenewalInterval(15 minutes). Only then is the store written and the session cookie re-sent with the remaining lifetime asMax-Age; every other request does no extra I/O. - A successful
POST /api/auth/refreshextends it too, with no throttle, because the record is written anyway and the provider has just accepted the refresh token. - A session never extends past
CreatedAt + SessionAbsoluteLifetime(24 hours). - Nothing else changes: the provider/issuer/generation key space, revocation and sign-out work as before. A session that does not authenticate — expired, signed out, failing native introspection after a revocation, or removed after its refresh token was refused — is never extended. A renewal re-reads the stored record and changes only its expiry, so it can neither write back a refresh token that a concurrent refresh has rotated nor recreate a record removed meanwhile. A store failure during renewal leaves the expiry as it was and never fails the request.
| Setting | Default | Why |
|---|---|---|
BffAuth:SessionLifetime |
08:00:00 |
Initial lifetime and, with sliding on, the idle timeout. Unchanged from before; equals the Auth0 BFF application's refresh-token idle lifetime (28 800 s). |
BffAuth:SessionAbsoluteLifetime |
1.00:00:00 (24 h) |
The Auth0 BFF application's refresh-token absolute lifetime (86 400 s) — the stricter provider. Native Identity's refresh tokens last 14 days (SetRefreshTokenLifetime, rolling) and its access tokens 1 hour, so a native session already re-proves its refresh token hourly; Auth0 sessions never call refresh, so these defaults keep a BFF session from outliving what its Auth0 refresh token could be renewed for. |
BffAuth:SessionRenewalInterval |
00:15:00 |
At most four store writes and Set-Cookie headers per active session per hour; an idle session can end up to 15 minutes before a strict idle timeout would. |
Startup validation requires SessionAbsoluteLifetime >= SessionLifetime and
0 < SessionRenewalInterval < SessionLifetime. With the flag off, sessions behave
exactly as before (8-hour absolute). Turning it on in staging: add
bffSlidingSession: true under featureFlags in
syrf/environments/staging/api/values.yaml, or set the runtime override.
Configuration Reference¶
API Environment Variables (BFF)¶
| Variable | Description | Default |
|---|---|---|
SYRF__BffAuth__Enabled |
Enable BFF endpoints | false |
SYRF__BffAuth__Authority |
OIDC provider URL (auto-derived from identity/auth0 when empty) | "" |
SYRF__BffAuth__ClientId |
OIDC client ID | syrf-web |
SYRF__BffAuth__ClientSecret |
OIDC client secret (from K8s secret) | — |
SYRF__BffAuth__Scopes |
OIDC scopes | openid profile email offline_access syrf_api |
SYRF__BffAuth__SecureCookies |
Set Secure flag on cookies | true |
SYRF__BffAuth__SessionLifetime |
Session duration; with bffSlidingSession, the idle timeout |
08:00:00 |
SYRF__BffAuth__SessionAbsoluteLifetime |
Sliding-session ceiling from sign-in (details) | 1.00:00:00 |
SYRF__BffAuth__SessionRenewalInterval |
Minimum time between sliding renewals of one session | 00:15:00 |
SYRF__BffAuth__FrontendOrigin |
Frontend URL (split-origin only) | "" |
SYRF__BffAuth__PkceCookieName |
Prefix of the per-flow PKCE cookies ({name}.{nonce}, see Sign-in flow cookies); E2E stacks override it |
syrf-pkce |
SYRF__BffAuth__Redis__Configuration |
Redis connection string (from K8s secret) | — |
SYRF__BffAuth__Redis__KeyPrefix |
Per-environment key prefix, e.g. staging: or pr-123: (details) |
"" |
Identity-management provider (IdentityProvider)¶
Separate from authentication (who the user is, handled by the BFF above),
this selects the backend used for identity management — the account
projection behind GET /api/account/account-management, password-reset mail,
and user lookups.
| Value | Backend | Requires |
|---|---|---|
openiddict |
OpenIddict admin API | IdentityService:BaseUrl |
auth0 |
Auth0 Management API | Auth0:ClientId, and a real Auth0:Tenant/Region |
none |
NoOpIdentityService — no identity management |
nothing |
Declaring a mode whose own configuration is missing stops startup rather than falling back to one that happens to work; an unrecognised value does the same.
When the key is absent the mode is inferred, which is what every environment did before the key existed:
IdentityService:BaseUrlset →openiddict- else
Auth0:ClientIdset and the OIDC authority is not loopback →auth0 - else →
none
The loopback test in rule 2 matters: the Auth0 Management API host is derived
from Auth0:Tenant/Region, not from the authority, so a mock OIDC provider on
localhost cannot have an Auth0 tenant behind it. Without that test, the
placeholder Auth0:ClientId that every settings file ships (CHANGEME,
local-dev-client, e2e-test-client) selected auth0 everywhere, made none
unreachable, and left local and e2e runs calling a tenant that does not exist —
GET /api/account/account-management returned 502 Bad Gateway on every page
load.
Local and e2e settings now declare "IdentityProvider": "none" outright.
Deployed environments still rely on the inference; set the key explicitly in
their values when convenient, and the inference can be removed once none are
left relying on it.
Startup logs which mode is in force and whether it was declared or inferred:
Identity provider: NoOp — no identity management (configured)
Identity provider: Auth0 Management API (inferred)
Web Configuration¶
The authProvider is auto-derived from bffAuth.enabled:
| Condition | Result | Effect |
|---|---|---|
bffAuth.enabled: true |
authProvider = "bff" |
Frontend uses cookies + /api/auth/* endpoints |
bffAuth.enabled: false (default) |
authProvider = "auth0" |
Frontend uses Auth0 Angular SDK (direct JWT) |
Explicit authProvider override |
Uses the override value | Manual control (e.g., "oidc" for E2E/mock) |
BFF Endpoints¶
| Endpoint | Method | Description |
|---|---|---|
/api/auth/login |
GET | Redirects to IdP login page; prompt=none for silent sign-in, plus mode=frame for the hidden-frame mode |
/api/auth/signup |
GET | Redirects to IdP signup page |
/api/auth/callback |
GET | OIDC callback — exchanges code for tokens, creates session; a refused silent attempt redirects to returnUrl with the failure marker (frame attempts: to /api/auth/silent-complete) |
/api/auth/callback/{appPath} |
GET | Auth0 Action app links built from redirect_uri: 302 to /{appPath} with the query unchanged, for a plain relative app path only; otherwise 404 (details) |
/api/auth/silent-complete |
GET | End of a frame-mode silent attempt: static page that posts { type: 'syrf-silent-auth', ok } to its same-origin parent |
/api/auth/me |
GET | Returns current user profile (or 401); both bodies carry the configured provider |
/api/auth/refresh |
POST | Refreshes session using refresh token (and, with bffSlidingSession, extends it) |
/api/auth/logout |
POST | Destroys session, sets the browser-session syrf-signed-out marker, returns the IdP logout URL when available (endSessionUrl) and, for provider auth0, the session-independent providerLogoutUrl (details) |
/api/auth/account/{page} |
GET | OpenIddict only: redirects to one Identity account page, {page} ∈ external-logins, passkeys, two-factor (/Account/Manage/ExternalLogins, /Passkeys, /TwoFactor); 404 for any other page and for Auth0/mock. Forwards a safe relative returnTo and an allowlisted theme (light, dark, system; the shared theme preference hint) |
My Account hand-off to Identity (OpenIddict)¶
In BFF mode the web app has no Identity URL and no client-side identity SDK, so
My Account changes sign-in methods on Identity's own pages. With the OpenIddict
BFF provider, My Account → Security (/manage/security, account portal
slice 2) shows the status from GET /api/account/account-management and links
each action to the API hand-off:
| Security row action | Hand-off | Identity page |
|---|---|---|
| Link Google / Remove | /api/auth/account/external-logins?returnTo=/manage/security |
/Account/Manage/ExternalLogins |
| Add passkey / Manage | /api/auth/account/passkeys?returnTo=/manage/security |
/Account/Manage/Passkeys |
| Turn on / Manage two-step verification | /api/auth/account/two-factor?returnTo=/manage/security |
/Account/Manage/TwoFactor |
{page} is a closed set (BffAccountPortal.Pages), so a request can never name
an Identity path; the destination origin is always BffAuth:Authority. The
hand-off is a top-level GET navigation that changes nothing; Identity enforces
sign-in, antiforgery and the operation-bound step-up itself. With the OpenIddict
provider, /manage/password and /manage/social-sign-in redirect to
/manage/security, and the side nav is Profile · Security · Deactivate
account. The web app learns the provider from the provider field of
/api/auth/me. Every other provider keeps today's pages, and the hand-off
answers 404 for it.
returnTo is accepted only as a relative app path: a single leading /, no //
or backslash, no whitespace or control characters, at most 512 characters.
Anything else is dropped (the page behaves as if it were absent). Identity joins
it to the configured web app origin (AppSettingsConfig:UiUrl, HTTPS or
loopback HTTP only) and renders Back to SyRF through the shared _AppReturn
partial, plus Back to My Account under a success notice; the destination is
never an absolute URL from the request, and no handler redirects to it. The value
is carried through every Manage page's own forms and post-redirects, the step-up
return URL, the provider callback and, after a removal (which ends the Identity
session), the sign-in that follows. Without returnTo every Identity URL and
redirect is unchanged. See
ADR-022.
Password row. "Send reset email" (or, with no password, "Email me a link to
set one") is the existing POST /api/account/profile/tickets/password, which
calls Identity's admin POST api/admin/users/password-reset. That email links
to Identity's /Account/ResetPassword beneath OpenIddict:Issuer, which sets a
first password on an account that has none.
Troubleshooting¶
GET /api/account/account-management returns 502¶
The API selected the Auth0 Management API but the tenant it derived does not exist. The log names it:
Check the startup line Identity provider: .... Locally and under e2e it should
read NoOp; if it reads Auth0 Management API, set IdentityProvider to
none for that environment. See
Identity-management provider.
Login redirects to 404¶
- Check
bffAuth.enabledistruein API config - Check the API pod has
SYRF__BffAuth__Enabled=trueenv var
Callback fails with "Missing PKCE verifier" (pkce_missing)¶
- The flow took longer than 10 minutes (for example a user left the Auth0 profile-completion or
confirmation page open), or the browser blocked or cleared the
/api/authcookies. Start the sign-in again. - More than five sign-ins were started in one browser at once; the oldest flow cookie was dropped.
- Overlapping flows no longer cause it; see Sign-in flow cookies.
An Auth0 confirmation link lands on /api/auth/callback/...¶
- Expected: the BFF forwards it to the app page. A 404 there means the path is not a plain relative app path (see Auth0 provider behaviour) or BFF is disabled.
Callback fails with "Token exchange failed"¶
- Verify the
client_secretmatches what the IdP expects - For Auth0: ensure the application type is "Regular Web Application" (confidential)
- Check the callback URL is in the IdP's allowed callback URLs
User data doesn't load after login¶
- Check the
signInRecorded$effect fires (browser console: "ABOUT TO RECORD SIGN-IN") - Verify
authProvideris"bff"in the web config (not"auth0")
Session lost across API replicas¶
- Configure the Redis session store (
bffAuth.redis.secretNameandbffAuth.redis.keyPrefix) - Without Redis, each replica has its own in-memory store
Auth0: "Unauthorized" on token exchange¶
- Ensure "Token Endpoint Authentication Method" is
Post(notBasic) - The BFF sends
client_secretin the POST body, not as an Authorization header