Skip to content
Merged
37 changes: 37 additions & 0 deletions api/src/main/resources/example.application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,43 @@ loginsvc:
# ldapFieldName: claimFieldName
mail: "email"
displayname: "displayname"
# MS Entra (Azure AD) Bearer token authentication provider.
# Users with a valid Entra access token can exchange it for a login-service JWT.
#entra:
# Set the order of the protocol starting from 1
# Set to 0 to disable or simply exclude the entra tag from config
# NOTE: At least 1 auth protocol needs to be enabled
#order: 0
# Azure AD tenant ID (directory ID)
#tenant-id: "your-tenant-id"
# Application (client) ID registered in Entra
#client-id: "your-client-id"
# Client secret used to call MS Graph API for on-premises username resolution.
# When set, the authenticated user's UPN is exchanged for their lower-case
# samAccountName
# via Graph API. Omit to use the UPN from the token directly.
#client-secret: "your-client-secret"
# Accepted JWT 'aud' claim values — tokens from any listed application are accepted;
# use an empty list to accept any token issued by the configured tenant
#audiences:
#- "api://your-client-id"
#- "other-app-client-id"
# Mapping from on-premises DNS domain names to NetBIOS short names.
# Required when client-secret is set and users have on-premises AD accounts.
# These mapped values are used to allow known domains and log the mapped AB value.
#domains:
#corp.example.com: "CORP"
#another.domain.com: "ANOTHER"
# Base URL for Microsoft login/token endpoints.
# Defaults to the public Azure cloud. Override for sovereign clouds.
#login-base-url: "https://login.microsoftonline.com"
# Base URL for the Microsoft Graph API.
# Defaults to the public Azure cloud. Override for sovereign clouds.
#graph-base-url: "https://graph.microsoft.com"
# Optional mapping from Entra JWT claim names to LS JWT claim names
#attributes:
#preferred_username: "upn"
#email: "email"

experimental:
# ability to enable experimental endpoints (default=false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.springframework.security.web.authentication.www.BasicAuthenticationFi
import org.springframework.security.web.{AuthenticationEntryPoint, SecurityFilterChain}
import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider
import za.co.absa.loginsvc.rest.provider.ad.ldap.LdapConnectionException
import za.co.absa.loginsvc.rest.provider.entra.{MsEntraBearerTokenFilter, MsEntraTokenValidator}
import za.co.absa.loginsvc.rest.provider.kerberos.KerberosSPNEGOAuthenticationProvider

import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
Expand All @@ -39,6 +40,8 @@ class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider, authM

private val ldapConfig = authConfigsProvider.getLdapConfig.orNull
private val isKerberosEnabled = authConfigsProvider.getLdapConfig.exists(_.enableKerberos.isDefined)
private val msEntraConfig = authConfigsProvider.getMsEntraConfig
private val isMsEntraEnabled = msEntraConfig.exists(_.order > 0)


@Bean
Expand Down Expand Up @@ -76,6 +79,11 @@ class SecurityConfig @Autowired()(authConfigsProvider: AuthConfigProvider, authM
classOf[BasicAuthenticationFilter])
}

if (isMsEntraEnabled) {
val entraFilter = new MsEntraBearerTokenFilter(MsEntraTokenValidator(msEntraConfig.get))
http.addFilterBefore(entraFilter, classOf[BasicAuthenticationFilter])
}

http.build()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginsvc.rest.config.auth

import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigValidationException, ConfigValidationResult}

/**
* Configuration for MS Entra (Azure AD) Bearer token authentication provider.
*
* @param tenantId Azure AD tenant ID (directory ID)
* @param clientId Application (client) ID registered in Entra
* @param clientSecret Client secret used to acquire a Graph API token for username resolution.
* When set, the token's `preferred_username` (UPN) is exchanged for
* `onPremisesSamAccountName` via MS Graph, and the resulting username
* is formatted as lower-case `samAccountName`.
* @param audiences Accepted JWT 'aud' claim values — tokens from any listed app are accepted;
* empty list accepts any token from the tenant
* @param domains Mapping from on-premises DNS domain names to their NetBIOS short names,
* e.g. `corp.example.com -> CORP`. Required when `clientSecret` is set
* so known domains can be allowed and their mapped AB values logged.
* @param order Provider ordering (0 = disabled, 1+ = active)
* @param attributes Optional mapping from Entra JWT claim names to LS JWT claim names
* @param loginBaseUrl Base URL for Microsoft login/token endpoints.
* Defaults to the public Azure cloud (`https://login.microsoftonline.com`).
* Override for sovereign clouds (e.g. Azure Government).
* @param graphBaseUrl Base URL for the Microsoft Graph API.
* Defaults to the public Azure cloud (`https://graph.microsoft.com`).
* Override for sovereign clouds (e.g. Azure Government).
*/
case class MsEntraConfig(
tenantId: String,
clientId: String,
clientSecret: Option[String] = None,
audiences: List[String],
domains: Option[Map[String, String]] = None,
order: Int,
attributes: Option[Map[String, String]],
loginBaseUrl: String = "https://login.microsoftonline.com",
graphBaseUrl: String = "https://graph.microsoft.com"
) extends ConfigValidatable with ConfigOrdering {

def throwErrors(): Unit =
this.validate().throwOnErrors()

override def validate(): ConfigValidationResult = {
if (order > 0) {
val results = Seq(
Option(tenantId)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("tenantId is empty"))),

Option(clientId)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("clientId is empty")))
)

results.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge)
} else ConfigValidationSuccess
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ import za.co.absa.loginsvc.rest.config.auth._
trait AuthConfigProvider {
def getLdapConfig : Option[ActiveDirectoryLDAPConfig]
def getUsersConfig : Option[UsersConfig]
def getMsEntraConfig : Option[MsEntraConfig]
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ class ConfigProvider(@Value("${spring.config.location}") yamlPath: String)
userConfigOption
}

def getMsEntraConfig: Option[MsEntraConfig] = {
val entraConfigOption = createConfigClass[MsEntraConfig]("loginsvc.rest.auth.provider.entra")
if (entraConfigOption.nonEmpty)
entraConfigOption.get.throwErrors()

entraConfigOption
}

private def getGitConfig: GitConfig = {
createConfigClass[GitConfig]("loginsvc.rest.config.git-info").
getOrElse(GitConfig(generateGitProperties = false, generateGitPropertiesFile = false))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginsvc.rest.provider.entra

import org.slf4j.LoggerFactory
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.OncePerRequestFilter
import za.co.absa.loginsvc.model.User

import javax.servlet.{FilterChain, ServletRequest, ServletResponse}
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
import scala.collection.JavaConverters._
import scala.util.{Failure, Success}

/**
* Spring Security filter that intercepts requests carrying an MS Entra Bearer token.
*
* When an `Authorization: Bearer <token>` header is present and no authentication
* is already established, delegates to [[MsEntraTokenValidator]] to validate the token
* and populate the [[SecurityContextHolder]].
*
* On invalid tokens the request is rejected with HTTP 401.
* On missing Bearer header the filter passes through, allowing other filters (e.g.
* BasicAuth) to handle authentication.
*/
class MsEntraBearerTokenFilter(validator: MsEntraTokenValidator) extends OncePerRequestFilter {

private val log = LoggerFactory.getLogger(classOf[MsEntraBearerTokenFilter])

private val BearerPrefix = "Bearer "

override def doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
): Unit = {
val authHeader = Option(request.getHeader("Authorization"))

authHeader match {
case Some(header) if header.startsWith(BearerPrefix) =>
// Only process if SecurityContext is not already populated
if (SecurityContextHolder.getContext.getAuthentication != null) {
filterChain.doFilter(request, response)
} else {
val rawToken = header.substring(BearerPrefix.length).trim
validator.validate(rawToken) match {
case Success(user) =>
log.info(s"Entra-based: Login of user ${user.name} - ok")
setAuthentication(user)
filterChain.doFilter(request, response)

case Failure(ex) =>
log.warn(s"Entra Bearer token rejected: ${ex.getMessage}")
SecurityContextHolder.clearContext()
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
response.setContentType("application/json")
response.getWriter.write(s"""{"error": "Invalid or expired Entra token"}""")
}
}

case _ =>
// No Bearer header — pass through to allow other auth mechanisms
filterChain.doFilter(request, response)
}
}

private def setAuthentication(user: User): Unit = {
val authorities = user.groups.map(new SimpleGrantedAuthority(_)).asJava
val authentication = new UsernamePasswordAuthenticationToken(user, null, authorities)
SecurityContextHolder.getContext.setAuthentication(authentication)
}
}
Loading
Loading