From fb58c18a1f9f8050a79511a7163f57b5eed17b00 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Mon, 20 Jul 2026 03:19:48 -0600 Subject: [PATCH 1/4] feat(ui): add identity provider sign-in to the login screen Users who authenticate through a federated provider configured in the realm (CILogon, EarthScope, ORCID) have no password in the realm, so the username/password form cannot work for them. Their only route in was to sign in on nationaldataplatform.org, find their access token and paste it by hand. Add a third way in that sends the user to the identity provider's own login page, where those providers are offered. The Authorization Code flow with PKCE is used against a public client, so no client secret has to be shipped with the Endpoint. The resulting token is handed to the existing setAndValidateToken path, so validation, storage and the access-denied (403) request-access flow behave exactly as they do for the other methods. The button is rendered only when OIDC_ISSUER and OIDC_CLIENT_ID are both set; deployments that leave them empty are unchanged. PKCE needs crypto.subtle, which browsers expose only in secure contexts, so over plain http the button is disabled with an explanation rather than silently downgrading to the weaker plain challenge method. Refs #193 --- entrypoint.sh | 8 +- example.env | 31 ++++ ui/src/components/AuthGuard.js | 153 ++++++++++++++++++- ui/src/services/oidc.js | 267 +++++++++++++++++++++++++++++++++ ui/src/services/oidc.test.js | 134 +++++++++++++++++ 5 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 ui/src/services/oidc.js create mode 100644 ui/src/services/oidc.test.js diff --git a/entrypoint.sh b/entrypoint.sh index dd04dec..9960b68 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,10 +3,16 @@ # Generate runtime config for the React UI. AFFINITIES_EP_UUID lets the # UI highlight the user's group that ties them to this endpoint without # having to look the value up against the deployment env manually. +# OIDC_ISSUER/OIDC_CLIENT_ID enable the "sign in through the identity +# provider" button; when either is empty the button is not rendered and the +# login screen keeps its previous behaviour. cat > /app/ui/build/config.js <://[:]/ui/auth/callback +# e.g. https://my-endpoint.example.org/ep-api/ui/auth/callback +# 3. The UI served over https. Browsers only expose the crypto API needed for +# PKCE in secure contexts, so on a plain-http/bare-IP deployment the button +# is shown disabled with an explanation. +# +# OIDC_ISSUER=https://idp.nationaldataplatform.org/realms/NDP +# OIDC_CLIENT_ID=ndp-ep-ui +# +# Optional. Defaults to "openid profile email" when empty. +# OIDC_SCOPE= + # ============================================== # External Service Integrations # ============================================== diff --git a/ui/src/components/AuthGuard.js b/ui/src/components/AuthGuard.js index 2379f5a..65d4fda 100644 --- a/ui/src/components/AuthGuard.js +++ b/ui/src/components/AuthGuard.js @@ -1,6 +1,13 @@ import React, { useState, useEffect } from 'react'; -import { Lock, AlertCircle, Eye, EyeOff, User, Send, CheckCircle } from 'lucide-react'; +import { Lock, AlertCircle, Eye, EyeOff, User, Send, CheckCircle, LogIn } from 'lucide-react'; import { accessRequestsAPI, authAPI } from '../services/api'; +import { + isOidcEnabled, + isOidcSupported, + isOidcCallback, + beginOidcLogin, + completeOidcLogin, +} from '../services/oidc'; /** * AuthGuard component that requires authentication before accessing the app @@ -20,6 +27,9 @@ const AuthGuard = ({ children, onAuthenticated }) => { const [showPassword, setShowPassword] = useState(false); const [loggingIn, setLoggingIn] = useState(false); + // Identity-provider sign-in state + const [redirectingToIdp, setRedirectingToIdp] = useState(false); + // Access-request flow state. The token is held in component state only — // it is never persisted because the user is not allowed into the app. const [deniedToken, setDeniedToken] = useState(null); @@ -42,6 +52,29 @@ const AuthGuard = ({ children, onAuthenticated }) => { */ useEffect(() => { const checkExistingAuth = async () => { + // Coming back from the identity provider takes priority over anything + // already in storage: the user explicitly asked to sign in again. + if (isOidcCallback()) { + try { + const userInfo = await completeOidcLogin(); + console.log('Identity provider sign-in successful:', userInfo); + + setIsAuthenticated(true); + onAuthenticated && onAuthenticated(); + } catch (signInError) { + console.error('Identity provider sign-in failed:', signInError); + // A 403 means the provider authenticated the user but this + // Endpoint denied entry — offer the access-request flow. + if (signInError.deniedToken) { + setDeniedToken(signInError.deniedToken); + } + setError(signInError.message || 'Sign-in failed. Please try again.'); + } + + setLoading(false); + return; + } + const existingToken = localStorage.getItem('authToken'); if (existingToken && existingToken.trim()) { @@ -227,6 +260,25 @@ const AuthGuard = ({ children, onAuthenticated }) => { setError(null); }; + /** + * Send the user to the identity provider's own login page, where the + * federated providers configured on the realm are offered. + */ + const handleIdpSignIn = async () => { + try { + setError(null); + clearRequestState(); + setRedirectingToIdp(true); + + await beginOidcLogin(); + // On success the browser navigates away; nothing below runs. + } catch (err) { + console.error('Could not start identity provider sign-in:', err); + setError(err.message || 'Could not reach the identity provider.'); + setRedirectingToIdp(false); + } + }; + /** * Show loading state */ @@ -514,6 +566,105 @@ const AuthGuard = ({ children, onAuthenticated }) => { )} + {/* Identity provider sign-in. Only rendered when the deployment + configures one; otherwise the screen is unchanged. */} + {!requestSubmitted && isOidcEnabled() && ( +
+ + + {isOidcSupported() ? ( +

+ Use your institutional credentials, EarthScope or ORCID. +

+ ) : ( +

+ Unavailable over an insecure connection. Use an access token + or your username and password below. +

+ )} + + {/* Divider */} +
+
+ or +
+
+
+ )} + {/* Token Form */} {!requestSubmitted && authMode === 'token' && (
diff --git a/ui/src/services/oidc.js b/ui/src/services/oidc.js new file mode 100644 index 0000000..88d2e34 --- /dev/null +++ b/ui/src/services/oidc.js @@ -0,0 +1,267 @@ +/** + * OpenID Connect (Authorization Code + PKCE) sign-in against the identity + * provider realm. + * + * This is an additional way into the UI, alongside the access-token and + * username/password forms. It exists because users who authenticate through + * a federated provider configured in the realm (CILogon, EarthScope, ORCID) + * have no password in the realm itself, so the credentials form cannot work + * for them and pasting a token by hand is their only alternative. + * + * A public client with PKCE is used rather than a confidential one so that no + * client secret has to be distributed with the Endpoint, which is self-hosted + * by many institutions. + * + * The flow ends by handing the resulting access token to + * ``authAPI.setAndValidateToken``, so validation, storage and the + * access-denied (403) handling are identical to the other sign-in methods. + */ + +import { authAPI, BASE_URL } from './api'; + +const STORAGE_VERIFIER = 'oidcCodeVerifier'; +const STORAGE_STATE = 'oidcState'; +const CALLBACK_PATH = '/ui/auth/callback'; + +/** + * Read the OIDC settings injected at container start into config.js. + * + * @returns {{issuer: string, clientId: string, scope: string}} + */ +const getConfig = () => ({ + issuer: (window.__EP_CONFIG__?.oidcIssuer ?? '').trim().replace(/\/+$/, ''), + clientId: (window.__EP_CONFIG__?.oidcClientId ?? '').trim(), + scope: (window.__EP_CONFIG__?.oidcScope ?? '').trim() || 'openid profile email', +}); + +/** + * Whether the deployment has configured an identity provider. Deployments that + * leave OIDC_ISSUER or OIDC_CLIENT_ID empty keep the previous behaviour and + * never see the button. + * + * @returns {boolean} + */ +export const isOidcEnabled = () => { + const { issuer, clientId } = getConfig(); + return Boolean(issuer && clientId); +}; + +/** + * Whether the browser can perform PKCE. + * + * ``crypto.subtle`` is only exposed in secure contexts, so a deployment served + * over plain http on a bare IP cannot compute an S256 challenge. Rather than + * silently downgrading to the ``plain`` challenge method, the button is + * disabled and the reason is shown — the other two sign-in methods still work. + * + * @returns {boolean} + */ +export const isOidcSupported = () => + Boolean(window.isSecureContext && window.crypto?.subtle); + +/** + * The redirect URI this deployment will use. It must be registered on the + * client in the realm, so it is also surfaced in the UI to make configuration + * mistakes diagnosable. + * + * @returns {string} + */ +export const getRedirectUri = () => + `${window.location.origin}${BASE_URL}${CALLBACK_PATH}`; + +/** + * Base64url-encode bytes without padding, as required for PKCE. + * + * @param {ArrayBuffer|Uint8Array} buffer + * @returns {string} + */ +const base64UrlEncode = (buffer) => { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +/** + * Generate a high-entropy random string suitable for a code verifier or state. + * + * @param {number} byteLength + * @returns {string} + */ +const randomString = (byteLength = 32) => + base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(byteLength))); + +/** + * Derive the S256 code challenge from a verifier. + * + * @param {string} verifier + * @returns {Promise} + */ +const deriveChallenge = async (verifier) => { + const digest = await window.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier) + ); + return base64UrlEncode(digest); +}; + +/** + * Fetch the realm's discovery document so the endpoints do not have to be + * hardcoded to a particular identity provider's URL layout. + * + * @returns {Promise} + */ +const discover = async () => { + const { issuer } = getConfig(); + const response = await fetch(`${issuer}/.well-known/openid-configuration`); + + if (!response.ok) { + throw new Error( + `Could not reach the identity provider (HTTP ${response.status}). ` + + 'Check that OIDC_ISSUER is correct.' + ); + } + + return response.json(); +}; + +/** + * Start the sign-in: build a PKCE challenge, remember the verifier and state, + * and send the browser to the realm's login page — where the federated + * providers configured on the realm are offered. + * + * @returns {Promise} Resolves as the browser navigates away. + */ +export const beginOidcLogin = async () => { + if (!isOidcEnabled()) { + throw new Error('No identity provider is configured for this Endpoint.'); + } + if (!isOidcSupported()) { + throw new Error( + 'Signing in through the identity provider requires a secure (https) ' + + 'connection. Use an access token or your username and password instead.' + ); + } + + const { clientId, scope } = getConfig(); + const config = await discover(); + + const verifier = randomString(); + const state = randomString(16); + + // sessionStorage, not localStorage: the verifier is single-use and must not + // outlive the tab or leak into other sessions. + sessionStorage.setItem(STORAGE_VERIFIER, verifier); + sessionStorage.setItem(STORAGE_STATE, state); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: getRedirectUri(), + scope, + state, + code_challenge: await deriveChallenge(verifier), + code_challenge_method: 'S256', + }); + + window.location.assign(`${config.authorization_endpoint}?${params.toString()}`); +}; + +/** + * Whether the current URL is the identity provider redirecting back to us. + * + * @returns {boolean} + */ +export const isOidcCallback = () => { + if (!window.location.pathname.endsWith(CALLBACK_PATH)) { + return false; + } + const params = new URLSearchParams(window.location.search); + return params.has('code') || params.has('error'); +}; + +/** + * Remove the authorization code from the address bar and return the user to + * the UI root, so a reload cannot replay a spent code. + */ +const clearCallbackUrl = () => { + window.history.replaceState({}, document.title, `${BASE_URL}/ui/`); +}; + +/** + * Complete the sign-in: exchange the authorization code for tokens, then hand + * the access token to the shared validation path. + * + * @returns {Promise} User information, once validated by the API. + */ +export const completeOidcLogin = async () => { + const params = new URLSearchParams(window.location.search); + const storedState = sessionStorage.getItem(STORAGE_STATE); + const verifier = sessionStorage.getItem(STORAGE_VERIFIER); + + sessionStorage.removeItem(STORAGE_STATE); + sessionStorage.removeItem(STORAGE_VERIFIER); + + // The provider reported a failure (for example the user cancelled). + if (params.has('error')) { + clearCallbackUrl(); + throw new Error( + params.get('error_description') || + `Sign-in was not completed (${params.get('error')}).` + ); + } + + const code = params.get('code'); + const returnedState = params.get('state'); + + if (!storedState || returnedState !== storedState) { + clearCallbackUrl(); + throw new Error( + 'Sign-in could not be verified. Please try again.' + ); + } + if (!verifier) { + clearCallbackUrl(); + throw new Error('Sign-in session has expired. Please try again.'); + } + + const { clientId } = getConfig(); + const config = await discover(); + + const response = await fetch(config.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: getRedirectUri(), + code_verifier: verifier, + }).toString(), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok || !data.access_token) { + clearCallbackUrl(); + throw new Error( + data.error_description || + data.error || + 'The identity provider did not issue a token.' + ); + } + + // Reuse the shared path so an Endpoint-level denial (403) surfaces exactly + // as it does for the other sign-in methods, carrying `deniedToken` so the + // access-request flow can be offered. + try { + const userInfo = await authAPI.setAndValidateToken(data.access_token); + clearCallbackUrl(); + return userInfo; + } catch (error) { + clearCallbackUrl(); + throw error; + } +}; diff --git a/ui/src/services/oidc.test.js b/ui/src/services/oidc.test.js new file mode 100644 index 0000000..f76bb19 --- /dev/null +++ b/ui/src/services/oidc.test.js @@ -0,0 +1,134 @@ +import { + isOidcEnabled, + isOidcSupported, + isOidcCallback, + getRedirectUri, + beginOidcLogin, +} from './oidc'; + +// ./api reads window.__EP_CONFIG__ and constructs an axios client at import +// time; only BASE_URL and the shared validation entry point matter here. +jest.mock('./api', () => ({ + BASE_URL: '/ep-api', + authAPI: { setAndValidateToken: jest.fn() }, +})); + +const setConfig = (config) => { + window.__EP_CONFIG__ = config; +}; + +const setUrl = (path) => { + window.history.replaceState({}, '', path); +}; + +describe('oidc configuration gate', () => { + afterEach(() => { + delete window.__EP_CONFIG__; + }); + + // The central guarantee of this feature: a deployment that does not + // configure an identity provider must behave exactly as it did before. + it('is disabled when no configuration is present', () => { + delete window.__EP_CONFIG__; + expect(isOidcEnabled()).toBe(false); + }); + + it('is disabled when the configuration is empty', () => { + setConfig({ oidcIssuer: '', oidcClientId: '' }); + expect(isOidcEnabled()).toBe(false); + }); + + it('is disabled when only the issuer is set', () => { + setConfig({ oidcIssuer: 'https://idp.example.org/realms/NDP' }); + expect(isOidcEnabled()).toBe(false); + }); + + it('is disabled when only the client id is set', () => { + setConfig({ oidcClientId: 'ndp-ep-ui' }); + expect(isOidcEnabled()).toBe(false); + }); + + it('is disabled when values are only whitespace', () => { + setConfig({ oidcIssuer: ' ', oidcClientId: ' ' }); + expect(isOidcEnabled()).toBe(false); + }); + + it('is enabled when both issuer and client id are set', () => { + setConfig({ + oidcIssuer: 'https://idp.example.org/realms/NDP', + oidcClientId: 'ndp-ep-ui', + }); + expect(isOidcEnabled()).toBe(true); + }); +}); + +describe('redirect URI', () => { + it('is built from the origin and the deployment root path', () => { + expect(getRedirectUri()).toBe( + `${window.location.origin}/ep-api/ui/auth/callback` + ); + }); +}); + +describe('callback detection', () => { + afterEach(() => { + setUrl('/'); + }); + + it('recognises a successful redirect back from the provider', () => { + setUrl('/ep-api/ui/auth/callback?code=abc&state=xyz'); + expect(isOidcCallback()).toBe(true); + }); + + it('recognises an error redirect back from the provider', () => { + setUrl('/ep-api/ui/auth/callback?error=access_denied'); + expect(isOidcCallback()).toBe(true); + }); + + it('ignores the callback path without any parameters', () => { + setUrl('/ep-api/ui/auth/callback'); + expect(isOidcCallback()).toBe(false); + }); + + it('ignores other pages that happen to carry a code parameter', () => { + setUrl('/ep-api/ui/search?code=abc'); + expect(isOidcCallback()).toBe(false); + }); +}); + +describe('starting the sign-in', () => { + afterEach(() => { + delete window.__EP_CONFIG__; + }); + + it('refuses when no identity provider is configured', async () => { + delete window.__EP_CONFIG__; + await expect(beginOidcLogin()).rejects.toThrow( + /No identity provider is configured/ + ); + }); + + // Browsers only expose crypto.subtle in secure contexts, so PKCE cannot be + // performed over plain http. The flow must refuse rather than silently + // downgrade to the weaker `plain` challenge method. + it('refuses over an insecure connection instead of downgrading PKCE', async () => { + setConfig({ + oidcIssuer: 'https://idp.example.org/realms/NDP', + oidcClientId: 'ndp-ep-ui', + }); + + const originalSecure = window.isSecureContext; + Object.defineProperty(window, 'isSecureContext', { + value: false, + configurable: true, + }); + + expect(isOidcSupported()).toBe(false); + await expect(beginOidcLogin()).rejects.toThrow(/secure \(https\) connection/); + + Object.defineProperty(window, 'isSecureContext', { + value: originalSecure, + configurable: true, + }); + }); +}); From 7265cdf459f1542cff0292ac705c9fb7dc859080 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Mon, 20 Jul 2026 03:39:47 -0600 Subject: [PATCH 2/4] fix(ui): report the real reason when the Endpoint rejects an OIDC token After the identity provider authenticated the user, any non-403 failure was surfaced through the shared "Invalid token: Authentication failed" wording, which reads as mistyped credentials. It is not: the sign-in succeeded and the Endpoint could not validate the resulting token. Seen in practice when the client mints tokens without a `sub` claim: AUTH_API_URL looks the user up by `sub`, returns 500, and the Endpoint turns that into a 401. Document the claim as a client requirement so the next deployment does not have to diagnose it from scratch. Refs #193 --- example.env | 9 ++++++++- ui/src/services/oidc.js | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/example.env b/example.env index a0b8122..6164b19 100644 --- a/example.env +++ b/example.env @@ -192,7 +192,14 @@ AUTH_API_URL=https://idp.nationaldataplatform.org/temp/information # that client: # ://[:]/ui/auth/callback # e.g. https://my-endpoint.example.org/ep-api/ui/auth/callback -# 3. The UI served over https. Browsers only expose the crypto API needed for +# 3. The client must issue the `sub` claim in its access tokens. AUTH_API_URL +# looks the user up by `sub`, so a token without it fails validation and +# the UI reports that the Endpoint could not validate the token. +# Since Keycloak 24 that claim comes from the built-in `basic` client +# scope. A realm imported from an older realm export may not have `basic` +# among its default scopes, in which case a newly created client silently +# mints tokens with no `sub` — assign the scope to the client explicitly. +# 4. The UI served over https. Browsers only expose the crypto API needed for # PKCE in secure contexts, so on a plain-http/bare-IP deployment the button # is shown disabled with an explanation. # diff --git a/ui/src/services/oidc.js b/ui/src/services/oidc.js index 88d2e34..3f16617 100644 --- a/ui/src/services/oidc.js +++ b/ui/src/services/oidc.js @@ -262,6 +262,23 @@ export const completeOidcLogin = async () => { return userInfo; } catch (error) { clearCallbackUrl(); - throw error; + + // A 403 is a real authorization decision: keep it as-is so the caller can + // offer the access-request form. + if (error.deniedToken) { + throw error; + } + + // Anything else happened *after* the provider already authenticated the + // user, so the shared "Invalid token" wording would wrongly suggest they + // mistyped their credentials. Say what actually failed instead. + const failure = new Error( + 'You signed in successfully, but this Endpoint could not validate the ' + + 'resulting token. This is a configuration problem on the Endpoint, ' + + 'not a problem with your credentials. ' + + `(${error.message || 'unknown error'})` + ); + failure.cause = error; + throw failure; } }; From dc911abe49b532bf60dbb4a5c56564fd1057a3b4 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Mon, 20 Jul 2026 04:04:41 -0600 Subject: [PATCH 3/4] feat(ui): make identity provider sign-in optional and fully configurable Put the feature behind OIDC_ENABLED, defaulting to False, so a deployment that says nothing keeps the login screen it already had. Switching it on without OIDC_ISSUER and OIDC_CLIENT_ID shows no button rather than one that fails the moment it is clicked. Move the button wording out of the build. It named the National Data Platform and its three federated providers, which is wrong for any other realm and for a realm that has none configured. OIDC_BUTTON_LABEL and OIDC_HELP_TEXT now supply it, defaulting to provider-neutral wording and to no second line at all, since only the deployment knows what its realm offers. Nothing about a particular identity provider is left in the build: the endpoints come from the realm's discovery document and the callback URL is derived from the browser's address and ROOT_PATH, so moving from a local Keycloak to production is a change of OIDC_ISSUER. Document the whole thing in example.env and docs/configuration.md, including what the identity provider administrator has to register and the two failure modes that cost real time to diagnose: a client that mints tokens without a sub claim, and OIDC_ISSUER disagreeing with AUTH_API_URL. Refs #193 --- docs/configuration.md | 80 ++++++++++++++++++++++++++++++++ entrypoint.sh | 12 +++-- example.env | 85 +++++++++++++++++++++++----------- ui/src/components/AuthGuard.js | 25 +++++----- ui/src/services/oidc.js | 48 ++++++++++++++++--- ui/src/services/oidc.test.js | 80 +++++++++++++++++++++++++++----- 6 files changed, 270 insertions(+), 60 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index dc22bcf..08ed867 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,86 @@ AAI administrator. --- +## Identity provider sign-in (optional, off by default) + +By default the UI login screen offers two ways in: paste an access token, or +type a username and password. Both assume the user holds a credential that +lives in the realm itself. + +Users who arrive through a **federated provider** configured on the realm — +CILogon (institutional credentials), EarthScope, ORCID — do not. They have no +password in the realm, so the username/password form cannot work for them, and +their only route in is to sign in on the platform's own site, find their access +token and paste it by hand. + +Turning this on adds a **third** button that sends the user to the identity +provider's own login page, where those providers appear. It uses the OAuth +Authorization Code flow with PKCE against a **public** client, so no client +secret is shipped with the Endpoint. + +The other two methods are unchanged and remain available either way. + +#### `OIDC_ENABLED` +*Optional · default: `False`.* +Master switch. While `False`, the login screen behaves exactly as before and +none of the values below are read. **Where:** you decide. + +#### `OIDC_ISSUER` +*Required when `OIDC_ENABLED=True`.* +The realm URL, e.g. `https://idp.nationaldataplatform.org/realms/NDP`. The +authorization, token and userinfo endpoints are read from this realm's OpenID +discovery document, so no provider-specific URL is hardcoded — moving from a +local Keycloak to production is just a change of this value. +**Where:** your AAI/Keycloak administrator. + +#### `OIDC_CLIENT_ID` +*Required when `OIDC_ENABLED=True`.* +The client registered for **this** Endpoint (see the requirements below). +**Where:** your AAI/Keycloak administrator. + +#### `OIDC_SCOPE` +*Optional · default: `openid profile email`.* + +#### `OIDC_BUTTON_LABEL` / `OIDC_HELP_TEXT` +*Optional · defaults: `Sign in with your identity provider` / empty.* +Wording for the button and the line under it. The defaults are +provider-neutral on purpose: only the deployment knows which providers its +realm offers. Naming providers the realm does **not** have is worse than +saying nothing, so `OIDC_HELP_TEXT` shows no second line while empty. + +### What your identity provider administrator must register + +1. **A public client with PKCE (S256).** Public rather than confidential so + that no client secret has to be distributed with the Endpoint, which is + self-hosted by many institutions. +2. **This deployment's callback URL** as a valid redirect URI on that client: + `://[:]/ui/auth/callback` — for example + `https://my-endpoint.example.org/ep-api/ui/auth/callback`. The Endpoint + derives this from the browser's address and `ROOT_PATH`, so you never + configure it, but it must match what is registered. +3. **The `sub` claim in access tokens.** `AUTH_API_URL` looks the user up by + `sub`. Since Keycloak 24 that claim comes from the built-in `basic` client + scope, and a realm imported from an older export may not have `basic` among + its default scopes — in which case a freshly created client silently mints + tokens without `sub`. Assign the scope to the client explicitly. + +### Two things that commonly go wrong + +**`OIDC_ISSUER` and `AUTH_API_URL` must point at the same identity provider.** +The token is minted by one and validated by the other. If they disagree, the +user's `sub` will not exist on the validating side and sign-in fails at the +last step — after a successful login, which makes it look like a credentials +problem when it is not. + +**The UI must be served over https.** Browsers only expose the crypto API that +PKCE needs in secure contexts. On a plain-http or bare-IP deployment the button +is shown **disabled** with an explanation rather than silently falling back to +the weaker `plain` challenge method; the other two sign-in methods still work +there. `localhost` counts as a secure context, so an SSH tunnel is enough for +testing. + +--- + ## Local catalog (where this EP stores its data) #### `LOCAL_CATALOG_BACKEND` diff --git a/entrypoint.sh b/entrypoint.sh index 9960b68..f30c551 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,16 +3,20 @@ # Generate runtime config for the React UI. AFFINITIES_EP_UUID lets the # UI highlight the user's group that ties them to this endpoint without # having to look the value up against the deployment env manually. -# OIDC_ISSUER/OIDC_CLIENT_ID enable the "sign in through the identity -# provider" button; when either is empty the button is not rendered and the -# login screen keeps its previous behaviour. +# OIDC_ENABLED gates the "sign in through the identity provider" button; it +# defaults to False so a deployment that says nothing keeps the login screen +# it had before. The realm URL, client and wording all come from the +# environment so nothing about a particular identity provider is baked in. cat > /app/ui/build/config.js <://[:]/ui/auth/callback # e.g. https://my-endpoint.example.org/ep-api/ui/auth/callback -# 3. The client must issue the `sub` claim in its access tokens. AUTH_API_URL -# looks the user up by `sub`, so a token without it fails validation and -# the UI reports that the Endpoint could not validate the token. +# The Endpoint derives this from the browser's address and ROOT_PATH, so it +# is never configured here — but it must match what is registered. +# +# 3. The client MUST issue the `sub` claim in its access tokens. AUTH_API_URL +# looks the user up by `sub`; a token without it fails validation and the +# UI reports that the Endpoint could not validate the token, even though +# the sign-in itself succeeded. # Since Keycloak 24 that claim comes from the built-in `basic` client -# scope. A realm imported from an older realm export may not have `basic` -# among its default scopes, in which case a newly created client silently -# mints tokens with no `sub` — assign the scope to the client explicitly. -# 4. The UI served over https. Browsers only expose the crypto API needed for -# PKCE in secure contexts, so on a plain-http/bare-IP deployment the button -# is shown disabled with an explanation. +# scope. A realm imported from an older export may not have `basic` among +# its default scopes, in which case a newly created client silently mints +# tokens with no `sub` — assign the scope to the client explicitly. # -# OIDC_ISSUER=https://idp.nationaldataplatform.org/realms/NDP -# OIDC_CLIENT_ID=ndp-ep-ui +# 4. OIDC_ISSUER and AUTH_API_URL must point at the SAME identity provider. +# The token is minted by one and validated by the other; if they disagree, +# the user's `sub` will not exist on the validating side and sign-in fails. # -# Optional. Defaults to "openid profile email" when empty. -# OIDC_SCOPE= +# 5. Serve the UI over https. Browsers only expose the crypto API that PKCE +# needs in secure contexts, so on a plain-http/bare-IP deployment the +# button is shown disabled with an explanation (`localhost` counts as +# secure, so an SSH tunnel works for testing). # ============================================== # External Service Integrations diff --git a/ui/src/components/AuthGuard.js b/ui/src/components/AuthGuard.js index 65d4fda..b72582f 100644 --- a/ui/src/components/AuthGuard.js +++ b/ui/src/components/AuthGuard.js @@ -7,6 +7,7 @@ import { isOidcCallback, beginOidcLogin, completeOidcLogin, + getOidcLabels, } from '../services/oidc'; /** @@ -576,7 +577,7 @@ const AuthGuard = ({ children, onAuthenticated }) => { disabled={redirectingToIdp || !isOidcSupported()} title={ isOidcSupported() - ? 'Sign in through the National Data Platform' + ? getOidcLabels().buttonLabel : 'Requires a secure (https) connection' } style={{ @@ -623,21 +624,23 @@ const AuthGuard = ({ children, onAuthenticated }) => { ) : ( <> - Sign in with National Data Platform + {getOidcLabels().buttonLabel} )} {isOidcSupported() ? ( -

- Use your institutional credentials, EarthScope or ORCID. -

+ getOidcLabels().helpText && ( +

+ {getOidcLabels().helpText} +

+ ) ) : (

+ ['true', '1', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); + /** * Read the OIDC settings injected at container start into config.js. * - * @returns {{issuer: string, clientId: string, scope: string}} + * Nothing here is hardcoded to a particular identity provider: the issuer + * carries the realm URL, the endpoints are read from its discovery document, + * and the wording is deployment-supplied so it can describe whichever + * providers that realm actually offers. + * + * @returns {{enabled: boolean, issuer: string, clientId: string, + * scope: string, buttonLabel: string, helpText: string}} */ const getConfig = () => ({ + enabled: parseBoolean(window.__EP_CONFIG__?.oidcEnabled), issuer: (window.__EP_CONFIG__?.oidcIssuer ?? '').trim().replace(/\/+$/, ''), clientId: (window.__EP_CONFIG__?.oidcClientId ?? '').trim(), scope: (window.__EP_CONFIG__?.oidcScope ?? '').trim() || 'openid profile email', + buttonLabel: + (window.__EP_CONFIG__?.oidcButtonLabel ?? '').trim() || + 'Sign in with your identity provider', + helpText: (window.__EP_CONFIG__?.oidcHelpText ?? '').trim(), }); /** - * Whether the deployment has configured an identity provider. Deployments that - * leave OIDC_ISSUER or OIDC_CLIENT_ID empty keep the previous behaviour and - * never see the button. + * Whether the deployment offers identity-provider sign-in. + * + * Off unless OIDC_ENABLED is switched on *and* the two values the flow cannot + * work without are present, so a half-configured deployment shows nothing + * rather than a button that fails on click. * * @returns {boolean} */ export const isOidcEnabled = () => { - const { issuer, clientId } = getConfig(); - return Boolean(issuer && clientId); + const { enabled, issuer, clientId } = getConfig(); + return Boolean(enabled && issuer && clientId); +}; + +/** + * Deployment-supplied wording for the button and the line under it. The help + * text is empty by default: only the deployment knows which providers its + * realm offers. + * + * @returns {{buttonLabel: string, helpText: string}} + */ +export const getOidcLabels = () => { + const { buttonLabel, helpText } = getConfig(); + return { buttonLabel, helpText }; }; /** diff --git a/ui/src/services/oidc.test.js b/ui/src/services/oidc.test.js index f76bb19..a6631bb 100644 --- a/ui/src/services/oidc.test.js +++ b/ui/src/services/oidc.test.js @@ -4,6 +4,7 @@ import { isOidcCallback, getRedirectUri, beginOidcLogin, + getOidcLabels, } from './oidc'; // ./api reads window.__EP_CONFIG__ and constructs an axios client at import @@ -26,40 +27,96 @@ describe('oidc configuration gate', () => { delete window.__EP_CONFIG__; }); - // The central guarantee of this feature: a deployment that does not - // configure an identity provider must behave exactly as it did before. + // The central guarantee of this feature: a deployment that does not ask for + // identity-provider sign-in must behave exactly as it did before. it('is disabled when no configuration is present', () => { delete window.__EP_CONFIG__; expect(isOidcEnabled()).toBe(false); }); - it('is disabled when the configuration is empty', () => { - setConfig({ oidcIssuer: '', oidcClientId: '' }); + it('is disabled by default even when issuer and client id are set', () => { + setConfig({ + oidcIssuer: 'https://idp.example.org/realms/NDP', + oidcClientId: 'ndp-ep-ui', + }); + expect(isOidcEnabled()).toBe(false); + }); + + it('is disabled when explicitly switched off', () => { + setConfig({ + oidcEnabled: 'False', + oidcIssuer: 'https://idp.example.org/realms/NDP', + oidcClientId: 'ndp-ep-ui', + }); expect(isOidcEnabled()).toBe(false); }); - it('is disabled when only the issuer is set', () => { - setConfig({ oidcIssuer: 'https://idp.example.org/realms/NDP' }); + // Half-configured deployments must show nothing rather than a button that + // fails the moment it is clicked. + it('stays disabled when switched on but the issuer is missing', () => { + setConfig({ oidcEnabled: 'True', oidcClientId: 'ndp-ep-ui' }); expect(isOidcEnabled()).toBe(false); }); - it('is disabled when only the client id is set', () => { - setConfig({ oidcClientId: 'ndp-ep-ui' }); + it('stays disabled when switched on but the client id is missing', () => { + setConfig({ + oidcEnabled: 'True', + oidcIssuer: 'https://idp.example.org/realms/NDP', + }); expect(isOidcEnabled()).toBe(false); }); - it('is disabled when values are only whitespace', () => { - setConfig({ oidcIssuer: ' ', oidcClientId: ' ' }); + it('stays disabled when the values are only whitespace', () => { + setConfig({ oidcEnabled: 'True', oidcIssuer: ' ', oidcClientId: ' ' }); expect(isOidcEnabled()).toBe(false); }); - it('is enabled when both issuer and client id are set', () => { + it('is enabled when switched on and fully configured', () => { setConfig({ + oidcEnabled: 'True', oidcIssuer: 'https://idp.example.org/realms/NDP', oidcClientId: 'ndp-ep-ui', }); expect(isOidcEnabled()).toBe(true); }); + + // `.env` elsewhere in this project uses Python-style True/False, so the + // usual spellings must all be understood. + it.each(['True', 'true', 'TRUE', '1', 'yes', 'on'])( + 'accepts %s as switched on', + (value) => { + setConfig({ + oidcEnabled: value, + oidcIssuer: 'https://idp.example.org/realms/NDP', + oidcClientId: 'ndp-ep-ui', + }); + expect(isOidcEnabled()).toBe(true); + } + ); +}); + +describe('deployment-supplied wording', () => { + afterEach(() => { + delete window.__EP_CONFIG__; + }); + + // Nothing about a particular identity provider may be baked into the build. + it('falls back to provider-neutral wording', () => { + delete window.__EP_CONFIG__; + const { buttonLabel, helpText } = getOidcLabels(); + expect(buttonLabel).toBe('Sign in with your identity provider'); + expect(helpText).toBe(''); + }); + + it('uses the label and help text the deployment supplies', () => { + setConfig({ + oidcButtonLabel: 'Sign in with National Data Platform', + oidcHelpText: 'Use your institutional credentials, EarthScope or ORCID.', + }); + const { buttonLabel, helpText } = getOidcLabels(); + expect(buttonLabel).toBe('Sign in with National Data Platform'); + expect(helpText).toBe('Use your institutional credentials, EarthScope or ORCID.'); + }); }); describe('redirect URI', () => { @@ -113,6 +170,7 @@ describe('starting the sign-in', () => { // downgrade to the weaker `plain` challenge method. it('refuses over an insecure connection instead of downgrading PKCE', async () => { setConfig({ + oidcEnabled: 'True', oidcIssuer: 'https://idp.example.org/realms/NDP', oidcClientId: 'ndp-ep-ui', }); From 7b8f548a03b48625e0128c0c338ec082aedbd57e Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Mon, 20 Jul 2026 04:04:41 -0600 Subject: [PATCH 4/4] chore(release): bump version to 0.33.0 Refs #193 --- CHANGELOG.md | 16 ++++++++++++++++ api/config/swagger_settings.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e181c39..ca9beee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.33.0] - 2026-07-20 + +### Added +- **Sign in through the identity provider.** The UI login screen can now send the user to the identity provider's own login page, where the federated providers configured on the realm (CILogon, EarthScope, ORCID) are offered. This matters because users arriving with institutional credentials have no password in the realm, so the username/password form never worked for them — their only way in was to sign in on nationaldataplatform.org, find their access token and paste it by hand. +- Off by default behind `OIDC_ENABLED`, and configured with `OIDC_ISSUER` and `OIDC_CLIENT_ID`. The Authorization Code flow with PKCE is used against a **public** client, so no client secret has to be shipped with the Endpoint. `OIDC_SCOPE` is optional and defaults to `openid profile email`. +- Nothing about a particular identity provider is baked into the build: the endpoints are read from the realm's OpenID discovery document, and the button wording comes from `OIDC_BUTTON_LABEL` / `OIDC_HELP_TEXT`, whose defaults are provider-neutral. Moving from a local Keycloak to production is a change of `OIDC_ISSUER`. +- The callback is handled at `/ui/auth/callback`, which must be registered as a valid redirect URI on the client. The client must also issue the `sub` claim — `AUTH_API_URL` looks the user up by it. `OIDC_ISSUER` and `AUTH_API_URL` must point at the same identity provider. See `example.env` and [docs/configuration.md](docs/configuration.md) for the full requirements. + +### Changed +- When the identity provider authenticates the user but the Endpoint cannot validate the resulting token, the UI now says so explicitly instead of reporting "Invalid token", which read as mistyped credentials even though the sign-in had succeeded. + +### Backwards compatibility +- The access token and username/password sign-in methods are unchanged and remain available. +- `OIDC_ENABLED` defaults to `False`, so existing deployments behave exactly as before until they opt in. A deployment that switches it on without both `OIDC_ISSUER` and `OIDC_CLIENT_ID` shows no button rather than one that fails on click. +- PKCE needs `crypto.subtle`, which browsers expose only in secure contexts. On a plain-http or bare-IP deployment the button is shown disabled with an explanation rather than silently downgrading to the weaker `plain` challenge method; the other two sign-in methods still work there. + ## [0.32.4] - 2026-06-05 ### Added diff --git a/api/config/swagger_settings.py b/api/config/swagger_settings.py index e605c2a..8955ec0 100644 --- a/api/config/swagger_settings.py +++ b/api/config/swagger_settings.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): swagger_title: str = "API Documentation" swagger_description: str = "This is the API documentation." - swagger_version: str = "0.32.4" + swagger_version: str = "0.33.0" root_path: str = "" # API root path prefix (e.g., "/test" or "") is_public: bool = True metrics_endpoint: str = "https://federation.ndp.utah.edu/metrics/"