Skip to content

Enforce the terms of use on the server, limit Bearer tokens to the federation's Keycloak clients, and publish the laptop sign-in client - #36

Open
KFilippopolitis wants to merge 4 commits into
masterfrom
feat/remote-api-token
Open

KFilippopolitis wants to merge 4 commits into
masterfrom
feat/remote-api-token

Conversation

@KFilippopolitis

@KFilippopolitis KFilippopolitis commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Researchers call the API with Bearer tokens from notebooks and from their own computers (see mip-jupyter #8). This PR makes the backend safe for that, and lets users' own computers sign in with a public Keycloak client.

  • Terms of use: they are now enforced on the server for sessions and Bearer clients alike.
  • Accepted tokens: a Bearer token is accepted only when Keycloak issued it to this federation's own client (azp). aud is ignored.
  • Roles: Bearer roles come only from the authorities claim and from this federation's client roles.
  • Laptop sign-in: GET /services/auth-config publishes the issuer and the public client (KEYCLOAK_CLI_CLIENT_ID), and tokens issued to that client are accepted.

Commits

  1. 023340f2 feat(security): enforce the terms of use on the server
  2. 6d4ec49d fix(security): accept only Bearer tokens issued to the federation's own client
  3. f6da383b fix(security): take Bearer roles only from the federation's own client
  4. cf7045ad feat(auth): publish the laptop sign-in client at GET /auth-config and accept its tokens

What changes

Terms of use on the server (TermsOfUseFilter)

Before this PR, the terms of use (NDA) were enforced only by the portal's TermsGuard, so any API client with a valid Bearer token could skip them.

  • The filter: TermsOfUseFilter is added to the security chain after AuthorizationFilter, and only when authentication.enabled is on. An authenticated user, session or Bearer JWT, who hasn't accepted the terms gets 403. It's rendered by ControllerExceptionHandler with the usual JSON error body: "Accept the MIP terms of use in the portal before using the API".
  • Exempt paths:
    • SecurityConfiguration.PUBLIC_PATHS, one list shared by permitAll and the filter: login, OAuth2, /auth-config, actuator, OpenAPI and Swagger.
    • GET /activeUser and POST /activeUser/agreeNDA.
    • /activeUser/token is not exempt.
  • agreeNDA takes portal sessions only: ActiveUserService.agreeToNDA accepts only portal sessions (OAuth2AuthenticationToken). A Bearer client gets the same 403, so it can't accept the terms on the user's behalf.
  • Cost of the check: hasAgreedToNDA does one findByUsername per request and never creates users. A Bearer user who has never opened the portal counts as not agreed.
  • Authentication-disabled mode is unchanged.

Bearer tokens only from this federation's own clients

  • The validator: keycloakClientValidator is an OAuth2TokenValidator<Jwt> bean, which Spring Boot adds to the issuer-uri JwtDecoder.
  • What it accepts: a token passes only when its azp is KEYCLOAK_CLIENT_ID (the platform client, shared by the portal and JupyterHub) or KEYCLOAK_CLI_CLIENT_ID (when set).
  • Why aud is ignored: Keycloak's default roles scope adds to aud every client the user has roles on. Another federation's token can therefore name this client in aud, and its authorities claim would grant that federation's roles here.
  • Empty client IDs: with both empty, every Bearer token is rejected.

Bearer roles only from this federation's client

  • Where roles come from now: Bearer authorities come only from the authorities claim and resource_access.<KEYCLOAK_CLIENT_ID>.roles.
  • What's dropped: realm roles (realm_access) and other clients' roles. They span every federation of the realm, so they grant nothing here.
  • Unchanged: if neither source has a role, the existing scope fallback still applies. Portal sessions still read only the ID token's authorities claim.

Laptop sign-in client (AuthConfigAPI)

  • The endpoint: GET /auth-config, served as /services/auth-config, is public and in PUBLIC_PATHS. It returns {"issuer", "clientId"}: the issuer is spring.security.oauth2.resourceserver.jwt.issuer-uri and the client is authentication.cli_client_id (KEYCLOAK_CLI_CLIENT_ID).
  • When it's off: it returns 404 when either value is empty, so the account page hides the feature and mip login says laptop sign-in isn't offered.
  • Tokens: the account page (platform-ui #53) and mip login (mip-jupyter fix: Ebrains harbor hanging when publishing images #8) get tokens for this client. The backend only publishes the client and validates the tokens like any other Bearer token.
  • Docs: the decisions are recorded in docs/context/decision-log.md, and KEYCLOAK_CLI_CLIENT_ID is documented in the README.

Configuration

Variable Default Effect
KEYCLOAK_CLIENT_ID (existing) Platform client. Now also the only azp accepted for portal and JupyterHub tokens, and the only client whose resource_access roles count
KEYCLOAK_CLI_CLIENT_ID (authentication.cli_client_id) empty (off) Public Keycloak client for laptop sign-in. When set, /auth-config returns it and tokens with that azp are accepted

Behaviour changes

  • Terms of use: authenticated users who haven't accepted them get 403 on data, specification, experiment, folder and token endpoints, from the notebook and laptop clients too, until they accept them in the portal. The portal flow is unchanged.
  • agreeNDA: a Bearer client calling POST /activeUser/agreeNDA gets 403.
  • Other clients' tokens: Bearer tokens whose azp is any other client, including other federations' clients, get 401, whatever their aud.
  • Lost roles: realm roles and other clients' resource_access roles no longer grant anything to Bearer users.
  • New public endpoint: GET /services/auth-config, which returns 404 unless KEYCLOAK_CLI_CLIENT_ID is set.

Rollout checklist

Check these in Keycloak for each federation before deploying:

  • JupyterHub client: JupyterHub's tokens must have azp equal to the backend's KEYCLOAK_CLIENT_ID, meaning JupyterHub uses the same client, as in mip/deployment. Otherwise notebooks get 401.
  • Dataset and experiment roles: research_dataset_* and research_experiment_all must be client roles of the federation's client, or be put into the access token's authorities claim by a mapper of that client that adds only its own roles. Otherwise users lose that access from the notebook and the laptop client.
  • CLI client profile scope: the CLI client (KEYCLOAK_CLI_CLIENT_ID) should include the profile scope, so its tokens carry preferred_username. The terms check and user lookup use it.
  • Issuer URL: the issuer that /auth-config returns (issuer-uri) must be a public https:// URL that users' computers can reach. The mip client refuses anything else.
  • Terms for Bearer users: each Bearer user must accept the terms in the portal once.
  • Shared client: federation-handson and hbpmip.link share mippublic, so they still accept each other's tokens. Give each federation its own client.
  • Rollout order: upgrade this backend in every federation before setting KEYCLOAK_CLI_CLIENT_ID anywhere (mip Feature/experiment folders #28).

How it was tested

  • Full build: mvn -B verify on cf7045ad gave 110 tests and 0 failures. These include TermsOfUseFilterTest (14), TermsOfUseSecurityChainTest (11, the real filter chain with a test JWT decoder), KeycloakClientValidatorTest (4), JwtAuthoritiesTest (3), AuthConfigAPITest (2) and ActiveUserServiceAgreeToNDATest (1).
  • Each commit: mvn -o test passed on each of the four commits.
  • Merged with master: the tree merged with current master merges cleanly, and 124 tests pass.
  • Not run:
    • No test against a live Keycloak, and the claims of a real Keycloak token weren't inspected.
    • The security scans and the container build/scan were skipped locally.
  • CI: MIP-org CI is currently blocked by a GitHub Actions billing lock, so the CI results don't reflect the code.

Related PRs

Known follow-ups

  • Validator wiring test: add a test that the azp validator is wired into the real issuer-uri JwtDecoder. The chain test uses a test decoder.
  • Startup log: log the accepted client IDs at startup.
  • profile scope: fall back to sub, or fail clearly, when a token has no preferred_username because the CLI client lacks the profile scope.
  • Public issuer override: allow overriding the issuer published by /auth-config when issuer-uri is an in-cluster URL.
  • Decision log: docs/context/decision-log.md entries are out of date order, and the "Accept only Bearer tokens…" entry still describes the abandoned aud approach in its context. Tidy both.
  • Username index: make sure user.username is indexed, since the terms check looks it up on every request.
  • Scope fallback: the scope fallback maps scopes without a SCOPE_ prefix, so a scope named like a role (e.g. research_dataset_all) would grant it. Restrict or prefix it.

The terms of use (NDA) were only enforced by the portal's TermsGuard, so
any API client with a valid Bearer token (the mip notebook client, or a
researcher's own computer) could use every endpoint without accepting
them.

When authentication is enabled, TermsOfUseFilter now runs in the security
chain right after AuthorizationFilter. An authenticated user, session or
Bearer, who has not agreed gets TermsOfUseNotAcceptedException, rendered by
ControllerExceptionHandler as the standard 403 ErrorMessage (a plain 403
if it cannot be rendered).

- Exempt: SecurityConfiguration.PUBLIC_PATHS (one list now shared by
  permitAll and the filter), GET /activeUser and POST /activeUser/agreeNDA.
  /activeUser/token is not exempt.
- ActiveUserService.agreeToNDA accepts only portal (OIDC session)
  logins, so an API client cannot accept the terms on a user's behalf.
- The check is one findByUsername per request and never creates users;
  a user who never opened the portal counts as not agreed.
- Authentication-disabled mode does not register the filter.

TermsOfUseSecurityChainTest runs the real SecurityConfiguration chain
with the real ActiveUserAPI and ControllerExceptionHandler, so moving the
filter before authentication fails the build.
…wn client

The resource server checked only the issuer, so any access token of the
realm was accepted, whichever client it was issued to. Once users' own
Keycloak tokens reach notebooks and laptops, another federation's portal
or notebook token could call this API, and its authorities claim (that
federation's roles) would grant access here.

Add an OAuth2TokenValidator<Jwt> bean, which Spring Boot applies to the
issuer-uri JwtDecoder: a token passes only when its azp (the client
Keycloak issued it to) is KEYCLOAK_CLIENT_ID, the client shared by the
portal and JupyterHub. aud is deliberately ignored: Keycloak's default
roles scope lists every client the user has roles on there. An empty
client ID rejects every Bearer token. Other tokens get 401.

The security-chain test's JwtDecoder now applies every validator bean,
as Spring Boot's does, and checks that a token of another client is
rejected.
jwtAuthenticationConverter collected the authorities claim, realm roles
(realm_access) and the roles of every client under resource_access.
Realm roles and other clients' roles apply across every federation of the
realm, so a role such as research_dataset_all defined there granted data
access on this platform.

Bearer authorities now come only from the authorities claim (read with the
same extractAuthorities as portal sessions) and
resource_access.<KEYCLOAK_CLIENT_ID>.roles. Portal sessions are unchanged:
they read the ID token's authorities claim.

Deployment: dataset and experiment roles (research_dataset_*,
research_experiment_all) must be client roles of each federation's client,
or put into the access token's authorities claim by a mapper of that
client. Users whose roles are realm roles lose that access through the
notebook and laptop clients.
… accept its tokens

Users' own computers now sign in with a public Keycloak client of the
federation instead of borrowing tokens from JupyterHub.

- KEYCLOAK_CLI_CLIENT_ID (authentication.cli_client_id, empty by
  default) names that client. Empty keeps the feature off.
- GET /auth-config, public and exempt from the terms-of-use filter,
  returns the issuer and that client ID so the mip client (`mip login`,
  device flow) and the portal's token page know where to sign in; it
  answers 404 when the client is not configured.
- The Bearer azp validator also accepts tokens issued to that client.
  Their roles still come only from the authorities claim and
  resource_access.<KEYCLOAK_CLIENT_ID>.roles, so the Keycloak client
  needs the platform client's roles and authorities mapper (see the
  decision log).
@KFilippopolitis KFilippopolitis changed the title Enforce the terms of use on the server and limit Bearer tokens to the federation's Keycloak client Enforce the terms of use on the server, limit Bearer tokens to the federation's Keycloak clients, and publish the laptop sign-in client Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant