Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions api/docker/bin/docker_entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"
Expand Down Expand Up @@ -69,27 +71,35 @@ 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
if [[ -n "${OAUTH_GOOGLE_CLIENT_ID:-}" ]] &&
[[ -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"
echo_debug "ENVIRONMENT after appending oauth2: '${ENVIRONMENT}'"
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"
Expand Down
12 changes: 11 additions & 1 deletion api/osmt-dev-stack.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 35 additions & 6 deletions api/src/main/kotlin/edu/wgu/osmt/security/AuthConfigProvider.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ClientRegistrationRepository>

@Autowired
lateinit var appConfig: AppConfig

@Value("\${app.baseUrl:http://localhost:8080}")
lateinit var baseUrl: String

Expand All @@ -27,12 +35,14 @@ class AuthConfigProvider {
val iterable = repo as? Iterable<ClientRegistration> ?: 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),
),
)
}
Expand All @@ -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")
}
Expand All @@ -55,4 +82,6 @@ data class AuthProviderInfo(
val id: String,
val name: String,
val authorizationUrl: String,
val iconUrl: String? = null,
val iconSlug: String? = null,
)
16 changes: 10 additions & 6 deletions api/src/main/kotlin/edu/wgu/osmt/ui/UiController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any> {
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
}
Expand Down
10 changes: 10 additions & 0 deletions api/src/main/resources/config/application-oauth2.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
5 changes: 5 additions & 0 deletions api/src/main/resources/config/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
107 changes: 107 additions & 0 deletions api/src/test/kotlin/edu/wgu/osmt/security/AuthConfigProviderTest.kt
Original file line number Diff line number Diff line change
@@ -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<ObjectProvider<ClientRegistrationRepository>>()
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()
}
}
11 changes: 9 additions & 2 deletions bin/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading