Skip to content

fix(auth): harden OIDC login flow and redirect handling - #6994

Open
snehasaisneha wants to merge 11 commits into
mainfrom
state_nonce
Open

fix(auth): harden OIDC login flow and redirect handling#6994
snehasaisneha wants to merge 11 commits into
mainfrom
state_nonce

Conversation

@snehasaisneha

@snehasaisneha snehasaisneha commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the website’s OIDC login flow and restricts the configured post-logout redirect by moving authorization initiation behind Astro-managed endpoints and adding the standard protections required for an authorization-code flow.

Addresses:

Previous Flow

sequenceDiagram
    autonumber
    participant B as Browser
    participant K as Keycloak
    participant W as Loculus website

    B->>K: Start login
    K-->>B: Return authorization code
    B->>W: Send callback code
    W->>K: Exchange code for tokens
    K-->>W: Return tokens

    Note over B,W: The callback was not bound to the browser session that started login
Loading

The callback did not validate state or nonce, and the authorization-code exchange did not use PKCE.

New Flow

sequenceDiagram
    autonumber
    participant B as Browser
    participant W as Loculus website (Astro)
    participant K as Keycloak

    B->>W: GET /auth/login
    W->>W: Generate state, nonce and PKCE verifier
    W-->>B: Set encrypted HTTP-only transaction cookie
    W-->>B: Redirect to Keycloak
    B->>K: Authorization request with state, nonce and PKCE challenge
    K-->>B: Redirect to /auth/callback with code and state
    B->>W: Send callback and transaction cookie
    W->>W: Validate and consume transaction
    W->>K: Exchange code with PKCE verifier
    K-->>W: Return tokens
    W->>W: Validate nonce and create session
    W-->>B: Redirect to the original Loculus page
Loading

What state, nonce and PKCE do

  • state answers: “Did this browser start this login?” Loculus generates a random value before redirecting to Keycloak, stores it in the protected transaction cookie, and requires Keycloak to return the same value. A callback without the matching value is rejected.

  • nonce answers: “Was this identity token issued for this login?” Loculus sends another random value to Keycloak and requires the resulting ID token to contain it. This prevents a token or authentication response from another login being reused.

  • PKCE answers: “Is the application redeeming the code the same one that started the login?” Loculus creates a secret verifier and sends only its derived challenge to Keycloak. When exchanging the authorization code, Loculus must supply the original verifier. A stolen code is therefore insufficient on its own.

The browser carries the encrypted, HTTP-only transaction cookie, but its contents are not available to browser JavaScript. The transaction is short-lived and consumed when its callback is received. It cannot be replayed, and a failed callback requires starting a new login.

Security Properties

  • State: binds the callback to the browser session that initiated login, preventing login CSRF.
  • Nonce: binds the returned ID token to the login transaction and prevents token-response replay.
  • PKCE (S256): binds the authorization code to the client that initiated the request.
  • Transaction cookie: stores the short-lived state, nonce, verifier and return destination in an encrypted and authenticated HTTP-only cookie.
  • Redirect allowlists: restrict login callbacks to /auth/callback and configure /logout separately as an exact post-logout destination.
  • Single use: consumes the matching transaction when its callback is received and rejects missing, expired, replayed or invalid callbacks.

Changes

  • Adds Astro-owned /auth/login and /auth/callback endpoints.
  • Routes website login links through /auth/login.
  • Moves callback validation and code exchange into the website’s authentication middleware.
  • Adds Helm-managed secret generation and runtime configuration for transaction-cookie encryption.
  • Adds tests for login initiation, callback validation, replay rejection and transaction-cookie handling.
  • Configures an exact login callback and an exact allowed post-logout redirect in Keycloak.

Documentation

  • Adds a browser authentication reference describing the numbered Keycloak and Astro login flow.
  • Explains the purpose of state, nonce, and PKCE, along with the encrypted, short-lived transaction cookie.
  • Documents callback validation failures, safe transaction logging, and troubleshooting steps.
  • Updates the system overview and runtime architecture documentation to reflect the new flow.
  • Clarifies that API authentication remains unchanged.

Compatibility

This does not change backend API authentication or password-based CLI authentication. Existing website login and logout behaviour remains the same from the user’s perspective.

No database migration is required.

Verification

  • Website unit tests, formatting and type checks pass.
  • Helm lint and configuration rendering pass.
  • CLI, Chromium and Firefox integration tests pass.
  • CodeQL checks pass.
  • Preview manually tested for existing test user, super user, and new user creation and login/logout flow.

🚀 Preview: https://state-nonce.loculus.org

@snehasaisneha snehasaisneha self-assigned this Jul 27, 2026
@claude claude Bot added website Tasks related to the web application deployment Code changes targetting the deployment infrastructure labels Jul 27, 2026
@snehasaisneha snehasaisneha added preview Triggers a deployment to argocd website Tasks related to the web application deployment Code changes targetting the deployment infrastructure and removed website Tasks related to the web application deployment Code changes targetting the deployment infrastructure labels Jul 27, 2026
@snehasaisneha snehasaisneha changed the title fix(auth): harden OIDC login and logout flows fix(auth): harden OIDC login flow and redirect handling Jul 27, 2026
@snehasaisneha
snehasaisneha marked this pull request as ready for review July 27, 2026 13:12
@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @snehasaisneha's task in 5m 9s —— View job


Review complete

Todo list

  • Gather context (read diff, changed files)
  • Understand the request (comprehensive code review)
  • Review security: state/nonce/PKCE, cookie handling, redirect allowlists
  • Review code quality & error handling
  • Review test coverage
  • Review documentation
  • Post inline comments for actionable issues
  • Post final summary

Summary

This is a well-designed and carefully implemented hardening of the OIDC login flow. I focused review effort on the security-critical paths: authRequestCookies.ts (transaction cookie sealing/unsealing), login.ts/callback.ts (Astro endpoints), authMiddleware.ts (callback validation), and the Keycloak/Helm redirect-URI restrictions.

Security fundamentals check out:

  • returnTo open-redirect protection uses new URL(returnTo, origin) + .origin comparison rather than a prefix check — this correctly rejects protocol-relative (//evil.com) and backslash-based bypass tricks, not just literal cross-origin URLs.
  • Transaction cookie uses AES-256-GCM (authenticated encryption) with a fresh IV per write, httpOnly, SameSite=Lax, and a 5-minute expiry enforced both via maxAge and server-side expiresAt filtering. Tampered/undecryptable cookies fail closed (unseal returns {}).
  • state lookup, nonce verification (via openid-client's client.callback), and PKCE (S256, verifier stored server-side only) are all wired correctly, and Keycloak's pkce.code.challenge.method: S256 attribute makes PKCE mandatory server-side too — consistent with not affecting the unrelated direct-grant (password) CLI flow.
  • Keycloak redirectUris and post.logout.redirect.uris are narrowed from wildcards to exact /auth/callback / /logout, addressing the linked security-issues.
  • Good test coverage for the transaction store (multi-transaction, eviction, expiry, tamper-rejection) and the login/callback endpoints.

Two minor, actionable points left as inline comments:

  1. website/src/middleware/authMiddleware.ts (getTokenFromParams) — when Keycloak returns an OIDC error response (e.g. access_denied) instead of a code, the function returns undefined with no log line, unlike every other rejection path which logs a reason=.... Worth logging params.error for troubleshooting parity with the documented failure reasons.
  2. website/src/pages/auth/login.spec.ts — no explicit regression test for a protocol-relative returnTo bypass (//attacker.test/...). The code handles it correctly today, but since this is the core open-redirect defense, it's worth locking in with a test.

No blocking issues found. Nice work — the documentation (browser-authentication-flow.md) is thorough and the mermaid diagrams make the flow easy to follow.
· branch

Comment thread website/src/middleware/authMiddleware.ts
Comment thread website/src/pages/auth/login.spec.ts
@snehasaisneha

Copy link
Copy Markdown
Member Author

@claude - changes have been pushed to resolve your previous comments. Please re-review.

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @snehasaisneha's task in 51s —— View job


Re-review complete

Todo list

  • Gather context (diff since last review)
  • Check if previous comment 1 (missing log on OIDC error response) was addressed
  • Check if previous comment 2 (missing protocol-relative returnTo test) was addressed
  • Review any other changes pushed since last review
  • Post final summary

Summary

Both points from the previous review have been fully resolved in e2462c8 (fix(auth): log provider errors and cover redirect bypass):

  1. OIDC provider-error logging (website/src/middleware/authMiddleware.ts:240-245) — getTokenFromParams now logs reason=provider_error error=<params.error> when Keycloak returns an error response instead of a code, matching the log format/style of the other rejection paths. This is exactly the fix suggested, and provider_error was also added to the documented failure-reason list in browser-authentication-flow.md. New test authMiddleware.spec.ts ("logs an error response returned by the OIDC provider") verifies both the log line and that client.callback is not invoked.

  2. Protocol-relative returnTo regression test (website/src/pages/auth/login.spec.ts) — new test "rejects a protocol-relative returnTo destination on another origin" asserts //attacker.test/after-login is rejected with 400 Invalid returnTo. I confirmed this matches the actual guard in login.ts:14-17: new URL('//attacker.test/after-login', origin) resolves to https://attacker.test/..., whose .origin differs from the request origin, so the existing check correctly rejects it — now locked in by a test.

No other files changed outside these two fixes (verified via git diff origin/main...HEAD --stat, which matches the PR's file list exactly). No new issues found; nothing further to flag.
· branch


The browser is redirected to Keycloak to enter credentials. Keycloak then returns an authorization code to the fixed `/auth/callback` endpoint on the Loculus website. Astro processes that callback on the server before the final page is rendered.

The requested destination is stored as `returnTo`. Loculus accepts only destinations on the same origin as the website, preventing the login flow from being used to redirect a user to an arbitrary external site.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loculus accepts only destinations on the same origin as the website, preventing the login flow from being used to redirect a user to an arbitrary external site.

just for my understanding which part of the flow does this?


- **State** is a random, single-use value that links the callback to the browser that started the login. It protects the login flow against cross-site request forgery.
- **Nonce** is a random value included in the authentication request and checked in the returned identity token. It prevents a response from another login transaction from being accepted.
- **PKCE** creates a one-time secret verifier and sends only its derived challenge with the initial request. The verifier is required when the website exchanges the authorization code, so an intercepted code is not sufficient to complete the login.

@anna-parker anna-parker Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it currently actually possible to use the intercepted code? I think the malicious user would need access to the website client_secret which astro doesnt expose (not against using PKCE as it is good practice just trying to understand our setup)


Loculus stores these values, together with `returnTo`, in an authenticated and encrypted HTTP-only cookie. The cookie:

- is valid for five minutes;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge deal, but this seems like bad UX (if I understand correctly). I may hit "Login", then I get a buzz on my phone and spend 5 mins sending a whatsapp message, then I fill in the login form and I'll presumably get an error message? I'd make this an hour or more

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In conversation with claude it pointed out that for the registration path - involving an email verification - this is especially short

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I think the current UX is not great. If staying at the login screen for more than 5 minutes, one gets afterwards:

Image

I think that the time should be extended. If one exceeds the time, one should get redirected back to the website with a useful message (like "Your session has expired, please click on login again.") - although what I find confusing here is that the authentication, at that point, will have been successfully performed by Keycloak already, so if one clicks on login, one gets directly logged in without needing to enter any credentials, that seems confusing and it's not clear to me how much security the time limt adds.

@theosanderson theosanderson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exact logout redirect restriction exposes a compatibility issue with the website’s current logout URL construction.

],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://{{$.Values.host}}/logout{{ if $.Values.insecureCookies }}##http://{{$.Values.host}}/logout##http://localhost:3000/logout{{ end }}"

@theosanderson theosanderson Aug 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[below comment is from Codex]

The exact allowlist here is incompatible with how UserPage.astro currently constructs post_logout_redirect_uri: it copies Astro.request.url and only replaces .pathname, so any query string on the account page is preserved. For example, visiting /user?foo=bar produces https://<host>/logout?foo=bar, which does not match this exact https://<host>/logout entry and Keycloak will reject the redirect.

Could we construct the logout URL from the origin instead (for example, new URL(routes.logout(), Astro.url.origin)) or explicitly clear search and hash before passing it to endSessionUrl? A regression test using a query-bearing /user URL would also be useful.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify what "Could we construct the logout URL from the origin instead" means: do you mean adapting UserPage.astro to not attach any ?foo=bar? Or do you mean adapting the keycloak config map here? (Do we have any use case for /logout?foo=bar?)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think Codex means "adapting UserPage.astro to not attach any ?foo=bar"

Afaik, we don't have a use case for /logout?foo=bar

secretKeyRef:
name: backend-keycloak-client-secret
key: backendKeycloakClientSecret
{{- if eq .name "loculus-website-config" }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditionality here is a different pattern to what's used for everything else - everything else is also only relevant in certain situations but we just include everything. I can see some argument for conditionality to minimise exposure - (although I think practically it's OK, since the secrets are contained to the config processor) - but if we wanted to change this I'd leave it until a future issue that changes all of them as otherwise I was left asking why this special one needs to be wrapped. (But nbd of course)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole pattern of putting secrets into config files should be avoided when we can. It turns out there's a new Astro feature which means we could: https://github.com/loculus-project/loculus/pull/7055/changes


logger.debug(`Redirecting to auth with redirect url: ${redirectUrl}`);
const authUrl = await getAuthUrl(redirectUrl);
const authUrl = getLoginUrl(redirectUrl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was identified by claude but I verified it https://state-nonce.loculus.org/user gives 500 when not logged in

getLoginUrl returns a relative path (/auth/login?returnTo=…), but authUrl flows into createRedirectWithModifiableHeaders, which calls Response.redirect() — and that throws on a relative URL. The old getAuthUrl returned an absolute Keycloak URL, which is why the comment removed from it read // Beware: relative url does not work with Redirect.response(). That warning went away along with the behaviour that satisfied it.

Driving the middleware directly with a logged-out context for /user:

TypeError: Invalid URL
 ❯ Proxy.redirect .../Response.ts:392:14
 ❯ createRedirectWithModifiableHeaders src/middleware/authMiddleware.ts:308:31
 ❯ redirectToAuth src/middleware/authMiddleware.ts:320:12
 ❯ Module.<anonymous> src/middleware/authMiddleware.ts:116:16

This hits every enforced-login route — /user, /<organism>/user, /<organism>/my_sequences — so a logged-out visit, or a session expiring while a user sits on "My sequences", returns a 500 instead of redirecting to login.

One-line fix:

Suggested change
const authUrl = getLoginUrl(redirectUrl);
const authUrl = new URL(getLoginUrl(redirectUrl), context.url.origin).toString();

The e2e suite misses this because AuthPage.login() always starts from / and clicks the nav Login link, which goes to /auth/login directly; the only test that visits /user (integration-tests/tests/pages/group.page.ts:89) is already authenticated. Worth adding coverage for the logged-out case.

Comment on lines 259 to +267
"redirectUris": [
"https://{{$.Values.host}}/*",
"http://{{$.Values.host}}/*",
"http://localhost:3000/*"
]
"https://{{$.Values.host}}/auth/callback"{{ if $.Values.insecureCookies }},
"http://{{$.Values.host}}/auth/callback",
"http://localhost:3000/auth/callback"{{ end }}
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://{{$.Values.host}}/logout{{ if $.Values.insecureCookies }}##http://{{$.Values.host}}/logout##http://localhost:3000/logout{{ end }}"
}

@theosanderson-agent theosanderson-agent Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drafted with the help of Claude.

Worth calling out in the PR description: the realm config map is only ever applied when the realm doesn't yet exist, so none of these Keycloak-side changes reach an instance that has already been deployed.

keycloak-deployment.yaml:113 starts Keycloak with --import-realm, which reads /opt/keycloak/data/import/ at boot but skips any realm already present in the database. That applies to the whole loculus realm definition, not just this client block — every change made to keycloak-config-map.yaml since a given instance's first boot is inert on that instance. This PR is just the first time that has security consequences. (The valuesHash pod annotation doesn't change it: it's gated on runDevelopmentKeycloakDatabase && !developmentDatabasePersistence, so it only covers throwaway dev databases, and a restart wouldn't re-import anyway.)

Concretely, on an existing instance backend-client keeps https://<host>/* and http://<host>/*, and gains neither pkce.code.challenge.method nor the post-logout allowlist.

Nothing breaks, which is what makes this easy to miss. The old wildcard still covers /auth/callback, and Keycloak honours a code_challenge even when the client doesn't mandate one, so state, nonce and PKCE all keep working end to end. What's silently absent is the server-side half — the narrowed redirect-URI allowlist and the exact post-logout destination, which is the part that addresses security-issues#20. Someone reading the PR description would reasonably assume that upgrading applies it.

Rather than adding an import-strategy override or a partial-import job to this PR, I'd suggest noting the caveat in the description and adding a short section admins can follow if they want the restriction on an existing realm. for-administrators/user-administration.md already covers reaching the admin console and selecting the loculus realm, so it could pick up from there.

E.g.

Per-client, via the admin console — Clients → backend-client:

  • Settings → Valid redirect URIs: replace the wildcards with https://<host>/auth/callback
  • Settings → Valid post logout redirect URIs: https://<host>/logout
  • Advanced → Proof Key for Code Exchange Code Challenge Method: S256

- **SILO(s):** [SILO](https://github.com/GenSpectrum/LAPIS-SILO) is an open-source query engine for genetic sequences optimized for high performance and supporting alignment-specific queries such as mutation searches. It regularly pulls data from the backend server and indexes them. By default, SILO is not exposed to the users but accessed via LAPIS. For each [organism](../../reference/glossary#organism) of a Loculus [instance](../../reference/glossary#instance), there is a separate instance of SILO.
- **LAPIS(es):** [LAPIS](https://github.com/GenSpectrum/LAPIS) provides a convenient interface to SILO, offering a lightweight web API and additional data and compression formats. For each SILO instance, there is a corresponding LAPIS instance.
- **Website:** The frontend application of Loculus accesses the APIs of the backend server and LAPIS. It uses the backend server for everything related to data submission and LAPIS for searching and downloading released data. For logins and registrations, users are redirected to Keycloak.
- **Website:** The frontend application of Loculus accesses the APIs of the backend server and LAPIS. It uses the backend server for everything related to data submission and LAPIS for searching and downloading released data. For browser login and registration, the website starts an OpenID Connect transaction and redirects the user to Keycloak. The website then validates Keycloak's response before establishing the session. See the [browser authentication flow](../../reference/browser-authentication-flow/) for details.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the change here as this is document is to give a quite high-level overview and this sounds quite technical already. Could this be phrased a bit simpler?


Loculus uses [Keycloak](https://www.keycloak.org/) as its OpenID Connect (OIDC) provider. Keycloak authenticates the user, while the Loculus website starts the login transaction, validates the response and establishes the website session.

This page describes browser login. It does not change [authentication via the API](../../for-users/authenticate-via-api/), which uses a separate flow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This page describes browser login. It does not change [authentication via the API](../../for-users/authenticate-via-api/), which uses a separate flow.
This page describes browser login. [Authentication via the API](../../for-users/authenticate-via-api/) follows a separate flow.


Loculus stores these values, together with `returnTo`, in an authenticated and encrypted HTTP-only cookie. The cookie:

- is valid for five minutes;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I think the current UX is not great. If staying at the login screen for more than 5 minutes, one gets afterwards:

Image

I think that the time should be extended. If one exceeds the time, one should get redirected back to the website with a useful message (like "Your session has expired, please click on login again.") - although what I find confusing here is that the authentication, at that point, will have been successfully performed by Keycloak already, so if one clicks on login, one gets directly logged in without needing to enter any credentials, that seems confusing and it's not clear to me how much security the time limt adds.

],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://{{$.Values.host}}/logout{{ if $.Values.insecureCookies }}##http://{{$.Values.host}}/logout##http://localhost:3000/logout{{ end }}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify what "Could we construct the logout URL from the origin instead" means: do you mean adapting UserPage.astro to not attach any ?foo=bar? Or do you mean adapting the keycloak config map here? (Do we have any use case for /logout?foo=bar?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deployment Code changes targetting the deployment infrastructure preview Triggers a deployment to argocd website Tasks related to the web application

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants