Skip to content

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 syrf tenant
  • 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.

  1. Log in to Auth0 Dashboard → tenant syrf
  2. Go to Applications → Create Application
  3. Settings: Name = SyRF BFF (or similar), Application Type = Regular Web Application

  4. 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:

https://staging.syrf.org.uk,
https://app.syrf.org.uk,
https://pr-2416.syrf.org.uk

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).

  1. Note the Client ID and Client Secret

Important: The BFF sends client_secret in the token request body (line 201 of BffAuthController.cs), not as a Basic Auth header. Ensure "Token Endpoint Authentication Method" is set to Post, not Basic.

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.secretName and bffAuth.redis.keyPrefix are 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 authProvider explicitly 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:

  1. API pod restarts with new env vars (check SYRF__BffAuth__Enabled=true)
  2. Web pod restarts with authProvider=bff in config
  3. Login flow: Navigate to the app → redirects to /api/auth/login → Auth0 Universal Login → callback → cookie set → app loads
  4. Session check: GET /api/auth/me returns user profile
  5. 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: null unless 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/logout therefore 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_id makes 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.uk for staging, FrontendOrigin when 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-verification and upgrade-social-to-auth0-user (docs/auth0-actions/) pause an unverified or upgrading user's sign-in on /auth/confirmation-email-sent or /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 reports provider (auth0, openiddict or mock, the configured BffAuth:Provider) in both its 200 and its 401 body. Any provider but auth0 does 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 call POST /api/auth/logout, which ends any BFF session;
  • load the response's providerLogoutUrl in 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:

  1. Helm chart: src/services/identity/.chart/ — deployed via ArgoCD
  2. Secrets required by the identity service:
  3. mongodb-identity — MongoDB connection string for identity data
  4. google-oauth — Google OAuth client ID and secret (reused from Auth0)
  5. ses-credentials — AWS SES credentials for email delivery
  6. openiddict-signing-key — JWT signing key
  7. openiddict-encryption-key — JWT encryption key
  8. DNS: identity.{env}.syrf.org.uk resolving 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:PkceCookieName to syrf-pkce-$E2E_STACK; the per-flow cookies become syrf-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

  1. On app start, when GET /api/auth/me answers 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.
  2. The API forwards prompt=none to the identity provider (OpenIddict or Auth0). none is the only accepted prompt value; any other value is not forwarded. The silent marker is stored in the server-set, HttpOnly PKCE cookie, never in state. 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.
  3. 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_required or account_selection_required. For a silent attempt (silent PKCE cookie and matching nonce) the callback clears the PKCE cookie, sets the syrf-silent-auth=failed marker cookie and redirects to the validated, local returnUrl. 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.
  4. Mid-session: when an API call returns 401 and POST /api/auth/refresh is 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/logout returns 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/logout itself, with client_id and an id_token_hint taken 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-sent and /auth/welcome-email-sent): the user has an Auth0 session but usually no BFF session, so the page ends it with {authority}/v2/logout in 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/logout sets the browser-session cookie syrf-signed-out=1 (Path=/, SameSite=Lax, Secure as 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=none with 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/login without prompt, 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=none reaches the provider. With the flag off, or without a runtime flag provider, a prompt=none request 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.

  1. Startup. When GET /api/auth/me answers 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=frame in a hidden iframe. The attempt is recorded in the same per-tab sessionStorage guard.
  2. API. mode=frame is honoured only together with prompt=none (anything else is ignored) and is recorded, like the silent flag, only in the server-set HttpOnly PKCE cookie — the provider and state never 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-complete instead of returnUrl. A refusal still sets the syrf-silent-auth=failed marker.
  3. /api/auth/silent-complete is 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 } to window.parent with its own origin as the target, ok being whether the request carried a valid BFF session — the same boolean /me already 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 /.
  4. 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 /me again 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's load event (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.
  5. Mid-session. When an API call gets 401 and POST /api/auth/refresh is 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 /me then 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=none with a plain 302 that carries neither X-Frame-Options nor a CSP (checked 2026-10-07 with an unauthenticated request: Identity staging 302 to the callback with error=login_required; Auth0 signin.syrf.org.uk/authorize?prompt=none 302 to the redirect URI with error=login_required), and browsers do not apply X-Frame-Options to redirect responses. The page the frame finally renders is the BFF's own silent-complete. Auth0's Universal Login pages do refuse framing, but a prompt=none request 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=none request never renders interactive UI — Identity redirects with a code or login_required/interaction_required, and OpenIddict renders only a static error for a request it cannot redirect — so there is nothing to click. Today /connect/authorize responses 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:

featureFlags:
  identitySilentSignInFrame: true

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 per SessionRenewalInterval (15 minutes). Only then is the store written and the session cookie re-sent with the remaining lifetime as Max-Age; every other request does no extra I/O.
  • A successful POST /api/auth/refresh extends 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:

  1. IdentityService:BaseUrl set → openiddict
  2. else Auth0:ClientId set and the OIDC authority is not loopback → auth0
  3. 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:

Auth0.Core.Exceptions.ErrorApiException: Unknown host: syrf-local.eu.auth0.com

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.enabled is true in API config
  • Check the API pod has SYRF__BffAuth__Enabled=true env 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/auth cookies. 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.
  • 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_secret matches 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 authProvider is "bff" in the web config (not "auth0")

Session lost across API replicas

  • Configure the Redis session store (bffAuth.redis.secretName and bffAuth.redis.keyPrefix)
  • Without Redis, each replica has its own in-memory store

Auth0: "Unauthorized" on token exchange

  • Ensure "Token Endpoint Authentication Method" is Post (not Basic)
  • The BFF sends client_secret in the POST body, not as an Authorization header