From 69160016bc97df691d1d5b67018d2a1e366c2f0c Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Thu, 23 Jul 2026 21:01:32 -0700 Subject: [PATCH] feat(auth): add generic OIDC provider with configurable label and icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a vendor-neutral `oidc` client registration alongside the existing `okta` and `google` slots, bound to its own OAUTH_OIDC_* variables so the change is fully additive and okta/google behavior is unchanged. Its callback path is /login/oauth2/code/oidc, and its sign-in button label (OAUTH_PROVIDER_NAME) and icon (OAUTH_PROVIDER_ICON_URL or a curated OAUTH_PROVIDER_ICON_SLUG) are configurable — no icon is shown for the generic provider unless one is set, so no vendor mark leaks onto the login page. Drops OAUTH_AUDIENCE from the OAuth activation gate in docker_entrypoint.sh and common.sh (it was required to enable oauth2 but never read; audience is not validated). The generic slot lets a PingFederate deployment migrate to Entra in place, keeping a stable redirect URI. Adds AuthConfigProviderTest (backend) and getIcon cases (UI). Updates the auth doc to introduce the generic provider and frame Okta as one example IdP. ADR: docs/adr/2026-07-21-generic-oidc-provider.md Plan: ~/.skybridge/planning/osmt/2026-07-21-generic-oidc-provider/plan.md Co-Authored-By: Claude Opus 4.8 --- api/docker/bin/docker_entrypoint.sh | 20 +++- api/osmt-dev-stack.env.example | 12 +- .../kotlin/edu/wgu/osmt/config/AppConfig.kt | 9 ++ .../wgu/osmt/security/AuthConfigProvider.kt | 41 ++++++- .../kotlin/edu/wgu/osmt/ui/UiController.kt | 16 ++- .../config/application-oauth2.properties | 10 ++ .../resources/config/application.properties | 5 + .../osmt/security/AuthConfigProviderTest.kt | 107 ++++++++++++++++++ bin/lib/common.sh | 11 +- docs/adr/2026-07-21-generic-oidc-provider.md | 71 ++++++++++++ docs/features/2026-02-28-auth.md | 87 +++++++++++++- ui/src/app/auth/login.component.html | 16 ++- ui/src/app/auth/login.component.spec.ts | 59 ++++++++++ ui/src/app/auth/login.component.ts | 47 +++++++- ui/src/app/models/app-config.model.ts | 4 + 15 files changed, 483 insertions(+), 32 deletions(-) create mode 100644 api/src/test/kotlin/edu/wgu/osmt/security/AuthConfigProviderTest.kt create mode 100644 docs/adr/2026-07-21-generic-oidc-provider.md diff --git a/api/docker/bin/docker_entrypoint.sh b/api/docker/bin/docker_entrypoint.sh index 281e4f30d..037afd32c 100755 --- a/api/docker/bin/docker_entrypoint.sh +++ b/api/docker/bin/docker_entrypoint.sh @@ -10,9 +10,11 @@ declare ELASTICSEARCH_URI="${ELASTICSEARCH_URI:-}" declare OAUTH_ISSUER="${OAUTH_ISSUER:-}" declare OAUTH_CLIENTID="${OAUTH_CLIENTID:-}" declare OAUTH_CLIENTSECRET="${OAUTH_CLIENTSECRET:-}" -declare OAUTH_AUDIENCE="${OAUTH_AUDIENCE:-}" declare OAUTH_GOOGLE_CLIENT_ID="${OAUTH_GOOGLE_CLIENT_ID:-}" declare OAUTH_GOOGLE_CLIENT_SECRET="${OAUTH_GOOGLE_CLIENT_SECRET:-}" +declare OAUTH_OIDC_ISSUER="${OAUTH_OIDC_ISSUER:-}" +declare OAUTH_OIDC_CLIENTID="${OAUTH_OIDC_CLIENTID:-}" +declare OAUTH_OIDC_CLIENTSECRET="${OAUTH_OIDC_CLIENTSECRET:-}" declare MIGRATIONS_ENABLED="${MIGRATIONS_ENABLED:-}" declare SKIP_METADATA_IMPORT="${SKIP_METADATA_IMPORT:-}" declare REINDEX_ELASTICSEARCH="${REINDEX_ELASTICSEARCH:-}" @@ -69,7 +71,7 @@ function validate() { # IMPORTANT: This logic MUST be kept in sync with detect_security_profile() in bin/lib/common.sh local -i has_oauth_okta=0 if [[ -n "${OAUTH_ISSUER:-}" ]] && [[ -n "${OAUTH_CLIENTID:-}" ]] && - [[ -n "${OAUTH_CLIENTSECRET:-}" ]] && [[ -n "${OAUTH_AUDIENCE:-}" ]]; then + [[ -n "${OAUTH_CLIENTSECRET:-}" ]]; then has_oauth_okta=1 fi local -i has_oauth_google=0 @@ -77,11 +79,18 @@ function validate() { [[ -n "${OAUTH_GOOGLE_CLIENT_SECRET:-}" ]]; then has_oauth_google=1 fi + # Generic OIDC provider (PingFederate, Entra, any OIDC IdP) - own OAUTH_OIDC_* vars + local -i has_oauth_oidc=0 + if [[ -n "${OAUTH_OIDC_ISSUER:-}" ]] && [[ -n "${OAUTH_OIDC_CLIENTID:-}" ]] && + [[ -n "${OAUTH_OIDC_CLIENTSECRET:-}" ]]; then + has_oauth_oidc=1 + fi - echo_debug "OAuth check: has_oauth_okta=${has_oauth_okta}, has_oauth_google=${has_oauth_google}" + echo_debug "OAuth check: has_oauth_okta=${has_oauth_okta}, has_oauth_google=${has_oauth_google}, has_oauth_oidc=${has_oauth_oidc}" echo_debug "ENVIRONMENT before OAuth logic: '${ENVIRONMENT}'" - if [[ ${has_oauth_okta} -eq 1 ]] || [[ ${has_oauth_google} -eq 1 ]]; then + if [[ ${has_oauth_okta} -eq 1 ]] || [[ ${has_oauth_google} -eq 1 ]] || + [[ ${has_oauth_oidc} -eq 1 ]]; then echo_info "OAuth credentials provided - will use oauth2 profile" if [[ "${ENVIRONMENT}" != *"oauth2"* ]]; then ENVIRONMENT="${ENVIRONMENT},oauth2" @@ -89,7 +98,8 @@ function validate() { fi fi # Add single-auth when no OAuth, or when ENABLE_SINGLE_AUTH=true (staging) - if [[ ${has_oauth_okta} -eq 0 ]] && [[ ${has_oauth_google} -eq 0 ]]; then + if [[ ${has_oauth_okta} -eq 0 ]] && [[ ${has_oauth_google} -eq 0 ]] && + [[ ${has_oauth_oidc} -eq 0 ]]; then echo_info "OAuth credentials not provided - will use single-auth profile" if [[ "${ENVIRONMENT}" != *"single-auth"* ]]; then ENVIRONMENT="${ENVIRONMENT},single-auth" diff --git a/api/osmt-dev-stack.env.example b/api/osmt-dev-stack.env.example index 6d0f07922..c285a9faf 100644 --- a/api/osmt-dev-stack.env.example +++ b/api/osmt-dev-stack.env.example @@ -7,10 +7,20 @@ # For local development without OAuth2 provider, you can leave these as 'xxxxxx' # # Okta: Replace with your OAuth2/OIDC values from Okta +# (OAUTH_AUDIENCE is optional and not required to activate the oauth2 profile.) OAUTH_ISSUER=https://xxxxxx.okta.com/oauth2/default OAUTH_CLIENTID=xxxxxx OAUTH_CLIENTSECRET=xxxxxx -OAUTH_AUDIENCE=xxxxxx +# +# Generic OIDC (PingFederate, Microsoft Entra, or any OIDC IdP). +# Callback path: {baseUrl}/login/oauth2/code/oidc +#OAUTH_OIDC_ISSUER=https://sso.example.edu +#OAUTH_OIDC_CLIENTID=xxxxxx +#OAUTH_OIDC_CLIENTSECRET=xxxxxx +# Button branding for the generic OIDC provider (optional; no icon shown unless set): +#OAUTH_PROVIDER_NAME=University SSO +#OAUTH_PROVIDER_ICON_URL=https://cdn.example.edu/sso.svg +#OAUTH_PROVIDER_ICON_SLUG=openid # # Google (alternative): Create OAuth credentials in Google Cloud Console #OAUTH_GOOGLE_CLIENT_ID=xxxxxx.apps.googleusercontent.com diff --git a/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt b/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt index b7e564afb..660502f4f 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt @@ -77,6 +77,15 @@ class AppConfig( val publicInstanceUrl: String, @Value("\${app.authoringWelcomeMessage:}") val authoringWelcomeMessage: String, + // Generic OIDC provider (oidc registration) button branding. Kept at the end + // with defaults so positional test constructors stay valid; Spring injects + // these via @Value regardless of the Kotlin defaults. + @Value("\${app.oauth2.oidc.providerName:Single sign-on}") + val oidcProviderName: String = "Single sign-on", + @Value("\${app.oauth2.oidc.iconUrl:}") + val oidcIconUrl: String = "", + @Value("\${app.oauth2.oidc.iconSlug:}") + val oidcIconSlug: String = "", ) { @Autowired lateinit var environment: Environment diff --git a/api/src/main/kotlin/edu/wgu/osmt/security/AuthConfigProvider.kt b/api/src/main/kotlin/edu/wgu/osmt/security/AuthConfigProvider.kt index 5a954c160..e85ab1d2b 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/security/AuthConfigProvider.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/security/AuthConfigProvider.kt @@ -1,5 +1,6 @@ package edu.wgu.osmt.security +import edu.wgu.osmt.config.AppConfig import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value @@ -11,12 +12,19 @@ import org.springframework.stereotype.Component * Provides OAuth provider information for the whitelabel API. * Uses ClientRegistrationRepository when available (oauth2 profile). * Iterates all registrations; custom providers appear without code changes. + * + * The generic `oidc` registration carries a configurable display name and + * optional icon (via app.oauth2.oidc.*); okta/google keep their built-in + * labels and no server-supplied icon. */ @Component class AuthConfigProvider { @Autowired lateinit var clientRegistrationRepositoryProvider: ObjectProvider + @Autowired + lateinit var appConfig: AppConfig + @Value("\${app.baseUrl:http://localhost:8080}") lateinit var baseUrl: String @@ -27,12 +35,14 @@ class AuthConfigProvider { val iterable = repo as? Iterable ?: return providers for (registration in iterable) { if (registration.clientId != "xxxxxx") { + val id = registration.registrationId providers.add( AuthProviderInfo( - id = registration.registrationId, - name = getDisplayName(registration.registrationId), - authorizationUrl = - "$baseUrl/oauth2/authorization/${registration.registrationId}", + id = id, + name = getDisplayName(id), + authorizationUrl = "$baseUrl/oauth2/authorization/$id", + iconUrl = iconUrlFor(id), + iconSlug = iconSlugFor(id), ), ) } @@ -41,11 +51,28 @@ class AuthConfigProvider { } private fun getDisplayName(registrationId: String): String = - KNOWN_PROVIDERS[registrationId] ?: registrationId.replaceFirstChar { - it.uppercase() + when (registrationId) { + GENERIC_OIDC_ID -> { + appConfig.oidcProviderName + } + + else -> { + KNOWN_PROVIDERS[registrationId] ?: registrationId.replaceFirstChar { + it.uppercase() + } + } } + // Icon is configurable only for the generic OIDC slot. okta/google get their + // built-in marks from the frontend, so the server supplies no icon for them. + private fun iconUrlFor(registrationId: String): String? = + appConfig.oidcIconUrl.takeIf { registrationId == GENERIC_OIDC_ID && it.isNotBlank() } + + private fun iconSlugFor(registrationId: String): String? = + appConfig.oidcIconSlug.takeIf { registrationId == GENERIC_OIDC_ID && it.isNotBlank() } + companion object { + private const val GENERIC_OIDC_ID = "oidc" private val KNOWN_PROVIDERS = mapOf("google" to "Google", "okta" to "Okta") } @@ -55,4 +82,6 @@ data class AuthProviderInfo( val id: String, val name: String, val authorizationUrl: String, + val iconUrl: String? = null, + val iconSlug: String? = null, ) diff --git a/api/src/main/kotlin/edu/wgu/osmt/ui/UiController.kt b/api/src/main/kotlin/edu/wgu/osmt/ui/UiController.kt index cfab215c6..86f42d2aa 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/ui/UiController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/ui/UiController.kt @@ -82,12 +82,16 @@ class UiController { } val providers = authConfigProvider?.getOAuthProviders() ?: emptyList() dynamicConfig["authProviders"] = - providers.map { - mapOf( - "id" to it.id, - "name" to it.name, - "authorizationUrl" to it.authorizationUrl, - ) + providers.map { provider -> + buildMap { + put("id", provider.id) + put("name", provider.name) + put("authorizationUrl", provider.authorizationUrl) + // Icon fields are present only for the generic oidc provider when + // configured; omitted (not null) otherwise, matching loginUrl above. + provider.iconUrl?.takeIf { it.isNotBlank() }?.let { put("iconUrl", it) } + provider.iconSlug?.takeIf { it.isNotBlank() }?.let { put("iconSlug", it) } + } } return dynamicConfig } diff --git a/api/src/main/resources/config/application-oauth2.properties b/api/src/main/resources/config/application-oauth2.properties index fa8dc69fd..9e408c30c 100644 --- a/api/src/main/resources/config/application-oauth2.properties +++ b/api/src/main/resources/config/application-oauth2.properties @@ -14,5 +14,15 @@ spring.security.oauth2.client.registration.google.client-id=${OAUTH_GOOGLE_CLIEN spring.security.oauth2.client.registration.google.client-secret=${OAUTH_GOOGLE_CLIENT_SECRET:xxxxxx} spring.security.oauth2.client.registration.google.scope=openid,profile,email +# Generic OIDC provider (PingFederate, Microsoft Entra, or any OIDC IdP). +# Bound to its own OAUTH_OIDC_* variables so it is additive: the okta and google +# registrations above are unaffected. Callback path is /login/oauth2/code/oidc. +# AuthConfigProvider hides registrations whose client-id is the xxxxxx sentinel. +# Branding (button label and icon) is configured separately via app.oauth2.oidc.*. +spring.security.oauth2.client.registration.oidc.client-id=${OAUTH_OIDC_CLIENTID:xxxxxx} +spring.security.oauth2.client.registration.oidc.client-secret=${OAUTH_OIDC_CLIENTSECRET:xxxxxx} +spring.security.oauth2.client.registration.oidc.scope=openid,profile,email +spring.security.oauth2.client.provider.oidc.issuer-uri=${OAUTH_OIDC_ISSUER:https://accounts.google.com} + # JWT resource server - primary issuer for bearer token validation spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH_JWT_ISSUER:${OAUTH_ISSUER:https://accounts.google.com}} diff --git a/api/src/main/resources/config/application.properties b/api/src/main/resources/config/application.properties index 7843603a7..800ec8058 100644 --- a/api/src/main/resources/config/application.properties +++ b/api/src/main/resources/config/application.properties @@ -76,6 +76,11 @@ app.publicKeywordLimit=1000 app.authMode=oauth2 # OAuth2 roles claim name for mapping token claims to authorities (e.g. roles, groups) app.oauth2.rolesClaim=roles +# Generic OIDC provider (oidc registration) button branding. Only affects the +# generic slot; okta/google keep their built-in labels and icons. +app.oauth2.oidc.providerName=${OAUTH_PROVIDER_NAME:Single sign-on} +app.oauth2.oidc.iconUrl=${OAUTH_PROVIDER_ICON_URL:} +app.oauth2.oidc.iconSlug=${OAUTH_PROVIDER_ICON_SLUG:} # Session token (OAuth2 backend-issued JWT) app.sessionTokenSecret=${APP_SESSION_TOKEN_SECRET:} app.sessionTokenExpirySeconds=${APP_SESSION_TOKEN_EXPIRY_SECONDS:86400} diff --git a/api/src/test/kotlin/edu/wgu/osmt/security/AuthConfigProviderTest.kt b/api/src/test/kotlin/edu/wgu/osmt/security/AuthConfigProviderTest.kt new file mode 100644 index 000000000..2cb377f57 --- /dev/null +++ b/api/src/test/kotlin/edu/wgu/osmt/security/AuthConfigProviderTest.kt @@ -0,0 +1,107 @@ +package edu.wgu.osmt.security + +import edu.wgu.osmt.config.AppConfig +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.ObjectProvider +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository +import org.springframework.security.oauth2.core.AuthorizationGrantType + +/** + * Unit test for [AuthConfigProvider] display-name and icon resolution. + * Uses an in-memory ClientRegistrationRepository (which is Iterable) so no + * Spring context or Docker is required. + */ +internal class AuthConfigProviderTest { + private lateinit var appConfig: AppConfig + + @BeforeEach + fun setUp() { + appConfig = mockk(relaxed = true) + every { appConfig.oidcProviderName } returns "University SSO" + every { appConfig.oidcIconUrl } returns "" + every { appConfig.oidcIconSlug } returns "" + } + + private fun registration( + id: String, + clientId: String, + ): ClientRegistration = + ClientRegistration + .withRegistrationId(id) + .clientId(clientId) + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/$id") + .authorizationUri("https://idp.example.com/authorize") + .tokenUri("https://idp.example.com/token") + .build() + + private fun providerWith(vararg registrations: ClientRegistration): AuthConfigProvider { + val repo: ClientRegistrationRepository = + InMemoryClientRegistrationRepository(registrations.toList()) + val objectProvider = mockk>() + every { objectProvider.getIfAvailable() } returns repo + + return AuthConfigProvider().apply { + clientRegistrationRepositoryProvider = objectProvider + this.appConfig = this@AuthConfigProviderTest.appConfig + baseUrl = "https://osmt.example.edu" + } + } + + @Test + fun `hides registrations with the xxxxxx sentinel client id`() { + val provider = providerWith(registration("oidc", "xxxxxx")) + assertThat(provider.getOAuthProviders()).isEmpty() + } + + @Test + fun `okta keeps its built-in display name and no server icon`() { + val provider = providerWith(registration("okta", "real-client")) + val result = provider.getOAuthProviders().single() + assertThat(result.name).isEqualTo("Okta") + assertThat(result.iconUrl).isNull() + assertThat(result.iconSlug).isNull() + } + + @Test + fun `oidc uses the configured provider name`() { + val provider = providerWith(registration("oidc", "real-client")) + val result = provider.getOAuthProviders().single() + assertThat(result.name).isEqualTo("University SSO") + assertThat(result.authorizationUrl) + .isEqualTo("https://osmt.example.edu/oauth2/authorization/oidc") + } + + @Test + fun `oidc carries a configured icon url`() { + every { appConfig.oidcIconUrl } returns "https://cdn.example.edu/sso.svg" + val provider = providerWith(registration("oidc", "real-client")) + val result = provider.getOAuthProviders().single() + assertThat(result.iconUrl).isEqualTo("https://cdn.example.edu/sso.svg") + assertThat(result.iconSlug).isNull() + } + + @Test + fun `oidc carries a configured icon slug`() { + every { appConfig.oidcIconSlug } returns "openid" + val provider = providerWith(registration("oidc", "real-client")) + val result = provider.getOAuthProviders().single() + assertThat(result.iconSlug).isEqualTo("openid") + assertThat(result.iconUrl).isNull() + } + + @Test + fun `icon config does not leak onto okta`() { + every { appConfig.oidcIconUrl } returns "https://cdn.example.edu/sso.svg" + val provider = providerWith(registration("okta", "real-client")) + val result = provider.getOAuthProviders().single() + assertThat(result.iconUrl).isNull() + } +} diff --git a/bin/lib/common.sh b/bin/lib/common.sh index c41a0e727..fffda9f2e 100755 --- a/bin/lib/common.sh +++ b/bin/lib/common.sh @@ -204,9 +204,11 @@ detect_security_profile() { return 0 fi - # Priority 2: Check for OAuth credentials (Okta or Google) + # Priority 2: Check for OAuth credentials (Okta, Google, or generic OIDC) + # IMPORTANT: This logic MUST be kept in sync with the inline detection in + # api/docker/bin/docker_entrypoint.sh if [[ -n "${OAUTH_ISSUER:-}" ]] && [[ -n "${OAUTH_CLIENTID:-}" ]] && - [[ -n "${OAUTH_CLIENTSECRET:-}" ]] && [[ -n "${OAUTH_AUDIENCE:-}" ]]; then + [[ -n "${OAUTH_CLIENTSECRET:-}" ]]; then echo "oauth2" return 0 fi @@ -215,6 +217,11 @@ detect_security_profile() { echo "oauth2" return 0 fi + if [[ -n "${OAUTH_OIDC_ISSUER:-}" ]] && [[ -n "${OAUTH_OIDC_CLIENTID:-}" ]] && + [[ -n "${OAUTH_OIDC_CLIENTSECRET:-}" ]]; then + echo "oauth2" + return 0 + fi # Priority 3: Default to single-auth when OAuth credentials are missing echo "single-auth" diff --git a/docs/adr/2026-07-21-generic-oidc-provider.md b/docs/adr/2026-07-21-generic-oidc-provider.md new file mode 100644 index 000000000..60c73f2c0 --- /dev/null +++ b/docs/adr/2026-07-21-generic-oidc-provider.md @@ -0,0 +1,71 @@ +# Generic OIDC provider (`oidc` registration) + +- Status: accepted +- Date: 2026-07-21 + +## Context + +OSMT authenticates staff against an OIDC identity provider. Historically the +only generic slot for a non-Google provider was a Spring Security client +registration named `okta`. Its issuer is supplied by `OAUTH_ISSUER`, so it +already worked with any OIDC IdP — but the name leaked into three user-visible +places: + +- the callback path `/login/oauth2/code/okta`, +- the login button label ("Okta", from a hardcoded map), and +- the login button **icon**, which the Angular UI rendered from the + `simple-icons` package keyed on the registration id — so a PingFederate + deployment displayed Okta's trademarked mark. + +Institutions want their own label and logo. One near-term client uses +PingFederate and will later move to Microsoft Entra. This repository is a fork +of `wgu-opensource/osmt`, whose upstream runs Okta, so changes should be +additive and mergeable rather than renames of shared files. + +Separately, `OAUTH_AUDIENCE` was required to activate the `oauth2` profile but +was never read by the application (the JWT resource server validates issuer, not +audience), making it a misleading, friction-adding knob. + +## Decision + +Add a fixed generic client registration with id `oidc`, bound to a **new** +`OAUTH_OIDC_ISSUER` / `OAUTH_OIDC_CLIENTID` / `OAUTH_OIDC_CLIENTSECRET` variable +set. Its callback path is `/login/oauth2/code/oidc`. Its button label +(`OAUTH_PROVIDER_NAME`, default "Single sign-on") and icon +(`OAUTH_PROVIDER_ICON_URL` or a curated `OAUTH_PROVIDER_ICON_SLUG`) are +configurable; the generic provider shows **no icon** unless one is configured. +The name and icon flow from the API through `/whitelabel/whitelabel.json` to the +login page. + +Drop `OAUTH_AUDIENCE` from the OAuth activation gate (in both +`docker_entrypoint.sh` and `bin/lib/common.sh::detect_security_profile`); the +`oauth2` profile now activates on issuer + client id + secret. + +The existing `okta` and `google` registrations are unchanged. + +## Alternatives considered + +- **Reuse `OAUTH_*` for the generic slot.** Rejected: both `okta` and `oidc` + would then activate from the same variables, producing two buttons for one + IdP. +- **Repoint `OAUTH_*` at `oidc` and retire `okta`.** Rejected: it would change + the callback path for existing Okta deployments from + `/login/oauth2/code/okta` to `/login/oauth2/code/oidc`, breaking a redirect + URI they have already registered, and would diverge from upstream. +- **Programmatic `ClientRegistrationRepository`** supporting arbitrary + runtime-named or multiple simultaneous generic providers. Rejected as + unnecessary: there is no requirement for two generic providers at once, and it + replaces Spring Boot autoconfiguration with more code and more upstream-merge + surface. + +## Consequences + +- Any OIDC IdP gets a vendor-neutral label, an optional own-branded icon, and a + stable `/login/oauth2/code/oidc` callback with no code change. +- A PingFederate → Entra migration is an in-place change of issuer and + credentials; the redirect URI does not change. +- The change is additive; `okta`/`google` behavior is byte-for-byte identical, + keeping the fork mergeable with upstream. +- Token audience remains unvalidated. Adding an optional audience + `OAuth2TokenValidator` is recorded as follow-up work, distinct from removing + the dead gate variable. diff --git a/docs/features/2026-02-28-auth.md b/docs/features/2026-02-28-auth.md index dc359b60e..067a01fd0 100644 --- a/docs/features/2026-02-28-auth.md +++ b/docs/features/2026-02-28-auth.md @@ -1,8 +1,9 @@ # OSMT Authentication This document describes how OSMT's authentication system works and how to -configure it for common deployments: single-auth (local dev), Okta OAuth2, -Google OAuth2, and staging (Google + single-auth). +configure it for common deployments: single-auth (local dev), a generic OIDC +provider (PingFederate, Microsoft Entra, or any OIDC IdP), Okta OAuth2, Google +OAuth2, and staging (OAuth + single-auth). ## Overview @@ -11,7 +12,7 @@ OSMT supports three authentication modes: | Mode | Profiles | Use case | |---------------|-------------------------|--------------------------------------------| | Single-auth | `single-auth` | Local development, testing, CI | -| OAuth2 | `oauth2` | Production (Okta, Google, or custom) | +| OAuth2 | `oauth2` | Production (generic OIDC, Okta, or Google) | | Staging | `oauth2,single-auth` | Staging with both OAuth and admin fallback | Profile selection is **automatic** based on environment variables. When running @@ -44,7 +45,9 @@ determines profiles from the `ENVIRONMENT` variable and OAuth credentials. The backend exposes auth configuration to the frontend via `/whitelabel/whitelabel.json`: -- `authProviders`: List of OAuth providers with `id`, `name`, `authorizationUrl` +- `authProviders`: List of OAuth providers with `id`, `name`, + `authorizationUrl`, and — for the generic `oidc` provider only — an optional + `iconUrl` or `iconSlug` - `singleAuthEnabled`: Whether the admin username/password form is shown - `authMode`: `oauth2` or `single-auth` @@ -60,7 +63,8 @@ in production or any environment exposed to the internet. ### When It’s Used - OAuth credentials are missing or left as `xxxxxx` -- No `OAUTH_ISSUER`, `OAUTH_CLIENTID`, `OAUTH_CLIENTSECRET`, `OAUTH_AUDIENCE` +- No `OAUTH_ISSUER`, `OAUTH_CLIENTID`, `OAUTH_CLIENTSECRET` +- No `OAUTH_OIDC_ISSUER`, `OAUTH_OIDC_CLIENTID`, `OAUTH_OIDC_CLIENTSECRET` - No `OAUTH_GOOGLE_CLIENT_ID`, `OAUTH_GOOGLE_CLIENT_SECRET` ### Configuration @@ -100,8 +104,77 @@ curl -H "Authorization: Bearer " http://localhost:8080/api/v3/skills --- +## Generic OIDC (PingFederate, Microsoft Entra, any OIDC IdP) + +The `oidc` registration is OSMT's vendor-neutral OIDC slot. Use it for any +OpenID Connect identity provider. Its callback path is +`{baseUrl}/login/oauth2/code/oidc`, and its sign-in button label and icon are +configurable, so nothing on the login page names or brands a specific vendor. + +The Okta and Google sections below are specific examples of OIDC providers; this +generic slot is the right choice for everything else. + +### Setup + +1. Create an OIDC web application (authorization code flow, confidential client) + in your IdP. +2. Sign-in redirect URI: `{baseUrl}/login/oauth2/code/oidc` + (e.g. `https://osmt-admin.example.edu/login/oauth2/code/oidc`). +3. Grant the `openid`, `profile`, and `email` scopes. +4. Configure a claim carrying group/role membership (see Role Mapping below). + +### Environment Variables + +| Variable | Description | +|---------------------------|------------------------------------------------------| +| `OAUTH_OIDC_ISSUER` | OIDC issuer URI (the IdP's `.well-known` issuer) | +| `OAUTH_OIDC_CLIENTID` | Client ID | +| `OAUTH_OIDC_CLIENTSECRET` | Client secret | +| `OAUTH_PROVIDER_NAME` | Button label (optional; default `Single sign-on`) | +| `OAUTH_PROVIDER_ICON_URL` | Button icon image URL (optional) | +| `OAUTH_PROVIDER_ICON_SLUG`| Bundled icon slug, e.g. `openid` (optional) | + +All three `OAUTH_OIDC_*` credential variables must be set to activate the +provider. If neither an icon URL nor an icon slug is configured, the button +shows a label with no icon. `OAUTH_PROVIDER_ICON_SLUG` resolves against a +curated set of bundled marks (`openid`, `keycloak`, `fusionauth`, and the +built-in `google`/`okta`/`github`/`apple`/`auth0`); for anything else, use +`OAUTH_PROVIDER_ICON_URL` with your own asset. + +### PingFederate + +Set `OAUTH_OIDC_ISSUER` to the PingFederate OIDC issuer and provide the client +id/secret. Map staff groups to OSMT roles via the roles claim. + +### Microsoft Entra ID + +Set `OAUTH_OIDC_ISSUER` to +`https://login.microsoftonline.com/{tenant-id}/v2.0`. Prefer **app roles** +(delivered in the `roles` claim) over the `groups` claim, which carries opaque +directory object IDs. Entra client secrets expire, so plan rotation. The +redirect URI is the same `{baseUrl}/login/oauth2/code/oidc`. + +### Migrating between IdPs + +Because the registration id is fixed at `oidc`, moving from one IdP to another +(for example PingFederate to Entra) is an in-place change of `OAUTH_OIDC_ISSUER` +and the credentials. The redirect URI you register with the IdP does not change. + +### Role Mapping + +Same as the other providers: OSMT reads the claim named by +`app.oauth2.rolesClaim` (default `roles`) and maps its values to OSMT roles +(e.g. `ROLE_Osmt_Admin`). See the Okta section's Role Mapping notes below for a +worked example. + +--- + ## Okta OAuth2 +Okta is one example of an OIDC provider. Any OIDC IdP can instead use the +[generic OIDC slot](#generic-oidc-pingfederate-microsoft-entra-any-oidc-idp) +above. + ### Prerequisites - [Okta Developer Account](https://developer.okta.com/signup) @@ -123,7 +196,9 @@ curl -H "Authorization: Bearer " http://localhost:8080/api/v3/skills | `OAUTH_ISSUER` | Okta issuer (e.g. `https://dev-xxx.okta.com/oauth2/default`) | | `OAUTH_CLIENTID` | Okta Client ID | | `OAUTH_CLIENTSECRET` | Okta Client Secret | -| `OAUTH_AUDIENCE` | Okta audience | + +`OAUTH_AUDIENCE` is accepted but optional; it is not required to activate the +`oauth2` profile and OSMT does not currently validate token audience. ### Configuration diff --git a/ui/src/app/auth/login.component.html b/ui/src/app/auth/login.component.html index 83af1aaae..01fb2cf29 100644 --- a/ui/src/app/auth/login.component.html +++ b/ui/src/app/auth/login.component.html @@ -43,9 +43,21 @@

> - + diff --git a/ui/src/app/auth/login.component.spec.ts b/ui/src/app/auth/login.component.spec.ts index 0d07a8a08..62c53e6ce 100644 --- a/ui/src/app/auth/login.component.spec.ts +++ b/ui/src/app/auth/login.component.spec.ts @@ -65,4 +65,63 @@ describe('LoginComponent', () => { const el: HTMLElement = fixture.nativeElement; expect(el.textContent).toContain('Sign In'); }); + + describe('getIcon', () => { + let component: LoginComponent; + + beforeEach(() => { + component = fixture.componentInstance; + }); + + it('renders no icon for the generic oidc provider with no icon config', () => { + const icon = component.getIcon({ + id: 'oidc', + name: 'University SSO', + authorizationUrl: 'https://example.com/oauth', + }); + expect(icon).toBeNull(); + }); + + it('uses an image URL when iconUrl is set on the oidc provider', () => { + const icon = component.getIcon({ + id: 'oidc', + name: 'University SSO', + authorizationUrl: 'https://example.com/oauth', + iconUrl: 'https://cdn.example.edu/sso.svg', + }); + expect(icon).toEqual({ + kind: 'url', + url: 'https://cdn.example.edu/sso.svg', + }); + }); + + it('resolves an allowlisted iconSlug to an svg mark', () => { + const icon = component.getIcon({ + id: 'oidc', + name: 'University SSO', + authorizationUrl: 'https://example.com/oauth', + iconSlug: 'openid', + }); + expect(icon?.kind).toBe('svg'); + }); + + it('renders no icon for an unknown iconSlug on the oidc provider', () => { + const icon = component.getIcon({ + id: 'oidc', + name: 'University SSO', + authorizationUrl: 'https://example.com/oauth', + iconSlug: 'pingfederate', + }); + expect(icon).toBeNull(); + }); + + it('keeps the built-in mark for google via id fallback', () => { + const icon = component.getIcon({ + id: 'google', + name: 'Google', + authorizationUrl: 'https://example.com/oauth', + }); + expect(icon?.kind).toBe('svg'); + }); + }); }); diff --git a/ui/src/app/auth/login.component.ts b/ui/src/app/auth/login.component.ts index 9bfd9ef01..f89c500b9 100644 --- a/ui/src/app/auth/login.component.ts +++ b/ui/src/app/auth/login.component.ts @@ -1,20 +1,44 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { siApple, siAuth0, siGithub, siGoogle, siOkta } from 'simple-icons'; +import { + siApple, + siAuth0, + siFusionauth, + siGithub, + siGoogle, + siKeycloak, + siOkta, + siOpenid, +} from 'simple-icons'; import { AuthService } from './auth-service'; import { AppConfig } from '../app.config'; import { AuthProvider } from '../models/app-config.model'; +// Curated allowlist of bundled simple-icons marks. An iconSlug resolves only +// against this map — we do not import the whole package (bundle size). Brands +// absent here (e.g. PingFederate, Microsoft Entra) are supplied via iconUrl. const PROVIDER_ICONS: Record = { apple: { path: siApple.path, hex: siApple.hex }, auth0: { path: siAuth0.path, hex: siAuth0.hex }, + fusionauth: { path: siFusionauth.path, hex: siFusionauth.hex }, github: { path: siGithub.path, hex: siGithub.hex }, google: { path: siGoogle.path, hex: siGoogle.hex }, + keycloak: { path: siKeycloak.path, hex: siKeycloak.hex }, okta: { path: siOkta.path, hex: siOkta.hex }, + openid: { path: siOpenid.path, hex: siOpenid.hex }, }; const ID_ALIASES: Record = {}; +// Registration id of the generic OIDC provider. It never falls back to a +// built-in mark: no icon appears unless the deployment configures one. +const GENERIC_OIDC_ID = 'oidc'; + +export type ResolvedIcon = + | { kind: 'url'; url: string } + | { kind: 'svg'; path: string; hex: string } + | null; + const DEFAULT_READ_ONLY_MESSAGE = "This is the public skill browser. Use your organization's authoring URL to edit."; @@ -75,9 +99,24 @@ export class LoginComponent implements OnInit { return this.oauthProviders.length >= 1 || this.singleAuthEnabled; } - getIcon(providerId: string): { path: string; hex: string } | null { - const slug = ID_ALIASES[providerId] ?? providerId; - return PROVIDER_ICONS[slug] ?? null; + getIcon(provider: AuthProvider): ResolvedIcon { + // 1. Explicit image URL wins (institution-hosted brand asset). + if (provider.iconUrl) { + return { kind: 'url', url: provider.iconUrl }; + } + // 2. Explicit bundled icon slug, resolved against the curated allowlist only. + const slug = provider.iconSlug ?? ID_ALIASES[provider.id]; + if (slug && PROVIDER_ICONS[slug]) { + const icon = PROVIDER_ICONS[slug]; + return { kind: 'svg', path: icon.path, hex: icon.hex }; + } + // 3. Built-in default keyed on registration id — never for the generic + // oidc provider, which shows no icon unless one is configured. + const builtin = PROVIDER_ICONS[provider.id]; + if (builtin && provider.id !== GENERIC_OIDC_ID) { + return { kind: 'svg', path: builtin.path, hex: builtin.hex }; + } + return null; } async onLogin(event?: Event): Promise { diff --git a/ui/src/app/models/app-config.model.ts b/ui/src/app/models/app-config.model.ts index cab07af36..6f51ef196 100644 --- a/ui/src/app/models/app-config.model.ts +++ b/ui/src/app/models/app-config.model.ts @@ -2,6 +2,10 @@ export interface AuthProvider { id: string; name: string; authorizationUrl: string; + // Optional button icon for the generic oidc provider: an image URL or a slug + // from the bundled simple-icons allowlist. Absent for okta/google. + iconUrl?: string; + iconSlug?: string; } export interface IAppConfig {