From 99d4fbd778025b6db7e37908e8adb455af49e529 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 11 Mar 2026 10:14:58 +0100 Subject: [PATCH 1/9] feat: add MS Entra Bearer token authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users holding a valid MS Entra (Azure AD) JWT to exchange it for a login-service access+refresh token pair via the existing /token/generate endpoint — alongside Basic Auth, LDAP, and Kerberos. Implementation details: - MsEntraConfig: PureConfig case class with tenant-id, client-id, audience, order and optional attributes map; implements ConfigValidatable - MsEntraTokenValidator: validates Entra JWTs via OIDC discovery + Nimbus JOSE; JWKS cached 1h via Guava LoadingCache; injectable JWKSource for tests - MsEntraBearerTokenFilter: OncePerRequestFilter; intercepts Authorization: Bearer headers; populates SecurityContext on success; returns 401 on failure - SecurityConfig: conditionally registers the filter before BasicAuthFilter when entra.order > 0 - AuthConfigProvider/ConfigProvider: getMsEntraConfig plumbing - Dependencies: add Guava CacheBuilder to apiDependencies - example.application.yaml: commented entra config block - Tests: MsEntraConfigTest, MsEntraTokenValidatorTest (real RSA key pair, no HTTP), MsEntraBearerTokenFilterTest (Mockito); all 153 tests pass --- .github/copilot-instructions.md | 131 +++ CODEBASE_ANALYSIS.md | 753 ++++++++++++++++++ ENTRA_INTEGRATION_GUIDE.md | 635 +++++++++++++++ .../main/resources/example.application.yaml | 17 + .../absa/loginsvc/rest/SecurityConfig.scala | 8 + .../rest/config/auth/MsEntraConfig.scala | 61 ++ .../config/provider/AuthConfigProvider.scala | 1 + .../rest/config/provider/ConfigProvider.scala | 8 + .../entra/MsEntraBearerTokenFilter.scala | 88 ++ .../entra/MsEntraTokenValidator.scala | 147 ++++ .../rest/config/auth/MsEntraConfigTest.scala | 79 ++ .../entra/MsEntraBearerTokenFilterTest.scala | 140 ++++ .../entra/MsEntraTokenValidatorTest.scala | 189 +++++ .../actuator/LdapHealthServiceTest.scala | 8 +- .../search/DefaultUserRepositoriesTest.scala | 3 +- project/Dependencies.scala | 5 + 16 files changed, 2268 insertions(+), 5 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 CODEBASE_ANALYSIS.md create mode 100644 ENTRA_INTEGRATION_GUIDE.md create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8746fc3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,131 @@ +# Copilot Instructions — login-service + +## Build & Test + +```bash +# Compile everything (cross-builds clientLibrary for 2.12 + 2.13) +sbt +compile + +# Run all tests +sbt +test + +# Run tests for a single module +sbt "api / test" +sbt "clientLibrary / test" + +# Run a single test class +sbt "api / testOnly za.co.absa.loginsvc.rest.controller.TokenControllerTest" + +# Run a single test by name fragment +sbt "api / testOnly *TokenControllerTest -- -z \"return tokens\"" + +# Code coverage (JaCoCo) — runs clean + test + report across all modules +sbt jacoco +# Reports at: {module}/target/jacoco/report/index.html + +# Start Tomcat locally (builds the WAR first) +sbt "api / Tomcat / start" +# Requires: --spring.config.location=api/src/main/resources/example.application.yaml +# or env: SPRING_CONFIG_LOCATION=api/src/main/resources/example.application.yaml + +# Generate cross-compiled docs +sbt +doc +``` + +CI runs: `sbt +test +doc` (see `.github/workflows/build.yml`). + +## Architecture + +### Module Layout + +- **`api`** — The service itself. Spring Boot 2.7 app deployed as a WAR on Tomcat (via `xsbt-web-plugin`). Scala 2.12 only. +- **`clientLibrary`** — Standalone JWT verification library for consumers. Cross-compiled for Scala 2.12 and 2.13. Published to Maven. +- **`examples`** — Usage examples; depends on `clientLibrary`. + +### Authentication Flow + +The service issues RS256-signed JWTs. Clients authenticate via one of several pluggable providers, then receive access + refresh tokens. + +``` +Client request (Basic Auth / SPNEGO / Bearer) + → Spring Security filter chain + → AuthenticationManager (ProviderManager with ordered providers) + → Provider authenticates, returns Authentication with User principal + → TokenController.generateToken(authentication) + → JWTService.generateAccessToken / generateRefreshToken + → Response: { "token": "...", "refresh": "..." } +``` + +**Pluggable provider system:** +- Providers are enabled/disabled and ordered via the `order` field in YAML config (`0` = disabled, `1+` = active, lower = higher priority). +- `AuthManagerConfig` builds the `ProviderManager` from `AuthConfigProvider` results. +- `DefaultUserRepositories` builds a parallel ordered list of `UserRepository` implementations for user lookup during token refresh. +- When adding a new auth provider, you must wire it into: `AuthConfigProvider` trait, `ConfigProvider`, `AuthManagerConfig`, `SecurityConfig`, and `DefaultUserRepositories`. + +**Existing providers:** +| Provider | Config Class | Auth Mechanism | +|---|---|---| +| Config Users | `UsersConfig` | Username/password from YAML | +| AD LDAP | `ActiveDirectoryLDAPConfig` | LDAP bind against Active Directory | +| Kerberos SPNEGO | `KerberosConfig` (nested in LDAP) | SPNEGO filter before BasicAuthFilter | + +### Configuration System + +All config is read from a single YAML file (path via `spring.config.location`) using **PureConfig** — not Spring's property binding. + +- `ConfigProvider` reads the YAML with `YamlConfigSource` and exposes typed config via traits: `AuthConfigProvider`, `JwtConfigProvider`, `ExperimentalRestConfigProvider`. +- Config classes live under `config/auth/` and implement `ConfigValidatable` (custom validation) + `ConfigOrdering` (the `order: Int` trait). +- Validation uses `ConfigValidationResult` (a sealed trait with `Success`/`Error` variants that merge via `foldLeft`). Call `throwErrors()` to fail fast on startup. + +### JWT Key Management + +Two mutually exclusive key strategies (configured under `loginsvc.rest.jwt`): +- `generate-in-memory` — RSA key pair generated at startup, with optional scheduled rotation/layover/phase-out. +- `aws-secrets-manager` — Fetches RSA keys from AWS Secrets Manager with periodic polling. + +`JWTService` handles generation, signing, refresh, and key rotation. It exposes keys as both raw `PublicKey` and `JWKSet`. + +### Token Refresh + +`JWTService.refreshTokens` validates both old access and refresh tokens, then calls `UserSearchService.searchUser(username)` to verify the user still exists and re-fetch their current groups. This means every auth provider should have a corresponding `UserRepository` implementation for refresh to work. + +## Conventions + +### New Auth Provider Checklist + +1. Config case class in `config/auth/` — extend `ConfigValidatable` with `ConfigOrdering` +2. Add to `AuthConfigProvider` trait and implement in `ConfigProvider` +3. `AuthenticationProvider` impl in `provider/` — return `UsernamePasswordAuthenticationToken` with `User` principal +4. Register in `AuthManagerConfig.createAuthProviders` pattern match +5. `UserRepository` impl in `service/search/` for token refresh support +6. Register in `DefaultUserRepositories.createUserRepositories` pattern match +7. Wire filter (if non-Basic-Auth) in `SecurityConfig.filterChain` +8. Add example config block to `example.application.yaml` +9. Add test coverage following existing patterns + +### File Headers + +All source files require the Apache 2.0 license header (enforced by `sbt-header` plugin). The header is auto-managed — don't add it manually; run `sbt headerCreate` if needed. + +### User Model + +`User(name: String, groups: Seq[String], optionalAttributes: Map[String, Option[AnyRef]])` is the universal principal. All providers must produce a `User` instance. The `optionalAttributes` map carries extra claims (e.g., `email`, `displayname`) that get embedded in the access JWT. + +### Test Patterns + +- Unit tests use **ScalaTest** (`AnyFlatSpec` style) with **ScalaMock**. +- Controller integration tests extend `ControllerIntegrationTestBase` (bridges Spring's `TestContextManager` with ScalaTest lifecycle). Use `@WebMvcTest` + `MockMvc`. +- Test config: `api/src/test/resources/application.yaml` (uses in-memory keys, config-based users, LDAP disabled). +- Mock the `JWTService` in controller tests; test providers directly with their config classes. + +### Scala Specifics + +- Scala 2.12 for `api` module; use `scala.collection.JavaConverters._` (not `scala.jdk.CollectionConverters`). +- `clientLibrary` cross-compiles 2.12 + 2.13 — use `scala-collection-compat` there. +- Java 8 target bytecode (`-source 1.8 -target 1.8`) for backward compatibility. + +### Spring / Swagger + +- REST controllers use Spring MVC annotations. Swagger annotations from `springdoc-openapi-ui` (OpenAPI 3). +- `SecurityConfig` defines which paths are public (`permitAll`) vs authenticated. Update this when adding new public endpoints. +- Exception handling is centralized in `RestResponseEntityExceptionHandler` (`@ControllerAdvice`). diff --git a/CODEBASE_ANALYSIS.md b/CODEBASE_ANALYSIS.md new file mode 100644 index 0000000..444805d --- /dev/null +++ b/CODEBASE_ANALYSIS.md @@ -0,0 +1,753 @@ +# Login Service - Comprehensive Codebase Analysis + +## 1. OVERALL PROJECT STRUCTURE & TECHNOLOGY STACK + +### Language & Framework +- **Language**: Scala 2.12/2.13 +- **Build Tool**: SBT (Scala Build Tool) +- **Web Framework**: Spring Boot 2.7.8 +- **Security**: Spring Security 5.7.6 +- **Project Structure**: Multi-module SBT project with 3 modules: + 1. **api** - Main REST service with authentication/JWT logic + 2. **clientLibrary** - Client library for token validation + 3. **examples** - Example usage + +### Key Technologies +- **JWT Signing**: JJWT 0.11.5 (Java JWT library) with RS256 algorithm +- **Key Management**: Nimbus JOSE+JWT 9.31 +- **LDAP**: Spring Security LDAP +- **Kerberos**: Spring Security Kerberos (1.0.1.RELEASE) +- **AWS Integration**: AWS SDK (Secrets Manager, SSM Parameter Store) +- **Config**: PureConfig 0.17.2 (YAML-based) +- **API Documentation**: SpringDoc OpenAPI UI 1.6.14 (Swagger) + +### Build Files +- **Primary**: `/Users/ab006hm/Projects/login-service/build.sbt` (80 lines) +- **Dependencies**: `/Users/ab006hm/Projects/login-service/project/Dependencies.scala` +- **Plugins**: `/Users/ab006hm/Projects/login-service/project/plugins.sbt` + +--- + +## 2. AUTHENTICATION MECHANISMS & HOW THEY WORK + +### Multiple Auth Providers (Pluggable Architecture) +The service uses an **ordered provider pattern** allowing multiple auth methods with priority ordering: + +#### A. **Config-Based Users Authentication** +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/ConfigUsersAuthenticationProvider.scala` + +**Class**: `ConfigUsersAuthenticationProvider extends AuthenticationProvider` + +- Simple hardcoded username/password pairs defined in YAML config +- Stores users in a map: `knownUsersMap: Map[String, UserConfig]` +- Authenticates via `UsernamePasswordAuthenticationToken` (Spring Security) +- Returns a `User` principal with username, groups, and optional attributes +- **Use case**: Development/testing or small number of static users + +**Flow**: +``` +1. Client sends Basic Auth credentials +2. ConfigUsersAuthenticationProvider.authenticate() called +3. Looks up username in knownUsersMap +4. Compares password +5. Returns UsernamePasswordAuthenticationToken with User principal +``` + +#### B. **Active Directory LDAP Authentication** +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProvider.scala` (160 lines) + +**Class**: `ActiveDirectoryLDAPAuthenticationProvider extends AuthenticationProvider` + +- Wraps Spring Security's `ActiveDirectoryLdapAuthenticationProvider` +- Supports AD LDAP queries via LDAPS (typically port 636) +- **Service Account**: Username/password for LDAP queries (configurable via: + - Inline config (`in-config-account`) + - AWS Secrets Manager (`aws-secrets-manager-account`) + - AWS Systems Manager Parameter Store (`aws-systems-manager-account`) +- **Retry Logic**: Optional retry mechanism for LDAP failures (configurable attempts + delay) +- **Custom Attributes**: Maps LDAP fields to JWT claims (e.g., `mail` → `email`, `displayname` → `displayname`) +- **User Details Mapper**: `LDAPUserDetailsContextMapperWithOptions` extracts groups (authority) and custom attributes + +**Configuration** (from example.application.yaml): +```yaml +loginsvc: + rest: + auth: + provider: + ldap: + order: 2 + domain: "some.domain.com" + url: "ldaps://some.domain.com:636/" + searchFilter: "(samaccountname={1})" + serviceAccount: + accountPattern: "CN=%s,OU=Users,OU=Organization1,DC=my,DC=domain,DC=com" + inConfigAccount: + username: "svc-ldap" + password: "password" + attributes: + mail: "email" + displayname: "displayname" +``` + +#### C. **Kerberos SPNEGO Authentication** +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/kerberos/KerberosSPNEGOAuthenticationProvider.scala` + +**Class**: `KerberosSPNEGOAuthenticationProvider` + +- Negotiates Kerberos tokens (SPNEGO) in HTTP requests +- Uses **keytab file** for server-side authentication +- Wraps Spring Security Kerberos components: + - `KerberosServiceAuthenticationProvider` + - `SunJaasKerberosTicketValidator` + - `SpnegoAuthenticationProcessingFilter` +- Integrates with LDAP to fetch user details post-Kerberos validation +- **Configuration** (in ActiveDirectoryLDAPConfig): + ```yaml + enableKerberos: + krbFileLocation: "/etc/krb5.conf" + keytabFileLocation: "/etc/keytab" + spn: "HTTP/Host" + debug: true + ``` + +### Authentication Manager Setup +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala` + +**Class**: `AuthManagerConfig` + +- Creates a `ProviderManager` with **ordered list** of `AuthenticationProvider` instances +- Order determined by `order` field in config (1st, 2nd, etc.) +- Each auth method tried in sequence until one succeeds +- Validates that **at least one** auth method is enabled + +### Security Configuration +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala` + +**Class**: `SecurityConfig extends Configuration` + +**Key Components**: +- **Filter Chain**: + - Disables CSRF, enables CORS + - Stateless session management (no server-side sessions) + - Custom `BasicAuthenticationFilter` with special exception handling + - Kerberos filter added before BasicAuth if enabled +- **Public Endpoints** (no auth required): + - `/v3/api-docs*`, `/swagger-ui/**`, `/actuator/**` + - `/token/refresh` (accepts tokens in body) + - `/token/public-key`, `/token/public-keys`, `/token/public-key-jwks` +- **Protected Endpoints**: `/token/generate` (requires authentication) +- **Custom Entry Point**: Handles LDAP connection failures (504 status), other failures (401) + +--- + +## 3. TOKEN GENERATION & JWT LOGIC + +### Token Generation Flow + +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala` (323 lines) + +**Class**: `JWTService extends Service` + +#### Key Methods: + +**`generateAccessToken(user: User, isRefresh: Boolean = false): AccessToken`** +- Expires in: configurable (default 15 minutes from example config) +- Claims included: + - `sub` (subject): username + - `exp` (expiration): calculated from current time + accessExpTime + - `iat` (issued at): current time + - `kid` (key ID): public key thumbprint + - `groups`: list of user groups (Java List format) + - `type`: "access" token type + - **Custom attributes**: Any optional attributes from user (e.g., mail, displayname) +- Signed with: Private RSA key from `primaryKeyPair` + +**`generateRefreshToken(user: User): RefreshToken`** +- Expires in: configurable (default 9 hours from example config) +- Claims: + - `sub`, `exp`, `iat`, `type` ("refresh") + - **No groups or attributes** (minimal) +- Purpose: Can be exchanged for new access token via `/token/refresh` + +**`refreshTokens(accessToken: AccessToken, refreshToken: RefreshToken): (AccessToken, RefreshToken)`** +- Validates **both** tokens against current and previous public keys +- Parses old access token to extract user info +- Searches for user in repositories to verify still exists & get updated info +- Keeps intersection of old groups (only groups from original token) +- Returns newly generated access token + original refresh token + +### Key Rotation & Management + +**Inline Key Generation** (InMemoryKeyConfig): +- Generates RSA keypair on startup (or on schedule if rotation enabled) +- Stores current and optional previous keypair in memory +- **Key Rotation**: Scheduled via `ScheduledThreadPoolExecutor`: + - Rotates every `keyRotationTime` (e.g., 9 hours) + - Maintains previous key for `keyLayOverTime` (e.g., 15 mins overlap) + - Phases out old key after `keyPhaseOutTime` (e.g., 15 mins) + +**AWS Secrets Manager Keys** (AwsSecretsManagerKeyConfig): +- Fetches public/private key pair from AWS Secrets Manager +- Looks for "AWSCURRENT" (primary) and "AWSPREVIOUS" (secondary) versions +- Polls for updates every `pollTime` +- Respects key lay-over and phase-out periods +- See: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/jwt/AwsSecretsManagerKeyConfig.scala` (160 lines) + +### Token Response Structure + +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/model/TokensWrapper.scala` + +```scala +case class TokensWrapper( + @JsonProperty("token") token: String, // Access token (JWT) + @JsonProperty("refresh") refresh: String // Refresh token (JWT) +) + +case class AccessToken(token: String) extends Token +case class RefreshToken(token: String) extends Token + +object Token { + object TokenType extends Enumeration { + val Access = Value("access") + val Refresh = Value("refresh") + } +} +``` + +**JSON Response Example**: +```json +{ + "token": "eyJhbGc...(access token JWT)...zI1NiJ9", + "refresh": "eyJhbGc...(refresh token JWT)...zI1NiJ9" +} +``` + +--- + +## 4. ROUTING/API LAYER - ALL ENDPOINTS + +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/controller/TokenController.scala` (261 lines) + +**Base Path**: `/token` + +### POST /token/generate +- **Authentication**: Basic Auth or Negotiate (Kerberos) +- **Security**: `@SecurityRequirement(name = "basicAuth")` + `@SecurityRequirement(name = "negotiate")` +- **Query Parameters**: + - `group-prefixes` (optional): CSV list of group prefixes to filter JWT groups + - `case-sensitive` (optional, default=false): Case sensitivity for prefix matching +- **Returns**: `TokensWrapper` (access + refresh tokens) +- **Status**: 200 OK | 401 Unauthorized +- **Implementation**: Calls `jwtService.generateAccessToken()` and `jwtService.generateRefreshToken()` + +### GET /token/experimental/get-generate (Experimental) +- **Same as POST /token/generate** but via GET method +- **Requires**: `loginsvc.rest.experimental.enabled=true` +- **Returns**: `TokensWrapper` + +### POST /token/refresh +- **Authentication**: None required (tokens in body) +- **Request Body**: `TokensWrapper` containing both access and refresh tokens +- **Returns**: `TokensWrapper` (new access token + same refresh token) +- **Status**: 200 OK | 401 Unauthorized (expired/invalid) | 400 Bad Request (malformed) +- **Implementation**: Calls `jwtService.refreshTokens()` + +### GET /token/public-key +- **Authentication**: None (public endpoint) +- **Returns**: `PublicKey` object with Base64-encoded current public key +- **Response**: +```json +{ + "key": "MIIBIjANBgkqhkiG9w0BA..." +} +``` + +### GET /token/public-keys +- **Authentication**: None (public endpoint) +- **Returns**: `PublicKeySet` with list of current + previous public keys +- **Response**: +```json +{ + "keys": [ + { "key": "MIIBIjANBgkqhkiG9w0BA..." }, + { "key": "MIIBIjANBgkqhkiG9w0BA..." } + ] +} +``` + +### GET /token/public-key-jwks +- **Authentication**: None (public endpoint) +- **Returns**: JWKS (JSON Web Key Set) format per RFC 7517 +- **Response** (standard JWKS format): +```json +{ + "keys": [ + { + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9...", + "e": "AQAB", + "alg": "RS256", + "kid": "abc123" + } + ] +} +``` + +### Other Endpoints (Actuator) +- **GET /actuator/health** - Health check endpoint +- **GET /actuator/info** - Application info +- **GET /v3/api-docs** - OpenAPI 3 JSON +- **GET /v3/api-docs.yaml** - OpenAPI 3 YAML +- **GET /swagger-ui.html** - Swagger UI + +--- + +## 5. THIRD-PARTY AUTH INTEGRATIONS + +### **Current Integrations**: +1. **Active Directory LDAP** - Full support +2. **Kerberos/SPNEGO** - Full support (integrates with LDAP) +3. **AWS Services**: + - AWS Secrets Manager (for storing JWT keys and service account credentials) + - AWS Systems Manager Parameter Store (for service account credentials) + - AWS STS/SSO (dependencies included but not actively used) + +### **NO Current OAuth2 / SAML / Azure AD Integration** +- The search `grep -r "oauth\|saml\|azuread\|entra"` returned **no results** in the Scala code +- Only dependency on OAuth2 is `spring-security-oauth2-jose` (for JWT decoding utilities) + +### **Extensibility Notes**: +The architecture supports adding a new provider. Template would be: +1. Create new `class MyAuthProvider extends AuthenticationProvider` +2. Create config case class `MyAuthConfig extends ConfigValidatable with ConfigOrdering` +3. Add to `AuthManagerConfig.createAuthProviders()` pattern matching +4. Update `ConfigProvider` to load config +5. Implement `authenticate(authentication: Authentication): Authentication` + +--- + +## 6. CONFIGURATION PATTERNS - EXTERNAL CREDENTIALS/SECRETS + +### Main Configuration Loading +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala` (107 lines) + +**Class**: `ConfigProvider extends JwtConfigProvider with AuthConfigProvider with ExperimentalRestConfigProvider` + +- Loads YAML configuration via **PureConfig** +- Spring looks for YAML in standard locations (environment variable or command-line arg) +- Spring property: `spring.config.location` + +### Configuration File Structure +**Example**: `/Users/ab006hm/Projects/login-service/api/src/main/resources/example.application.yaml` + +```yaml +loginsvc: + rest: + # JWT Configuration + jwt: + generate-in-memory: # OR aws-secrets-manager + access-exp-time: 15min + refresh-exp-time: 9h + key-rotation-time: 9h + key-lay-over-time: 15min + key-phase-out-time: 15min + alg-name: "RS256" + + # Authentication Configuration + auth: + provider: + users: + order: 1 + known-users: + - username: "user1" + password: "password1" # PLAINTEXT (security risk!) + groups: [] + attributes: + displayname: "User One" + + ldap: + order: 2 + domain: "some.domain.com" + url: "ldaps://some.domain.com:636/" + searchFilter: "(samaccountname={1})" + serviceAccount: + accountPattern: "CN=%s,OU=Users,DC=domain,DC=com" + # Option 1: Inline credentials + inConfigAccount: + username: "svc-ldap" + password: "password" + # Option 2: AWS Secrets Manager + # awsSecretsManagerAccount: + # secretName: "my-ldap-secret" + # region: "us-east-1" + # usernameFieldName: "username" + # passwordFieldName: "password" + # Option 3: AWS Systems Manager + # awsSystemsManagerAccount: + # parameter: "/ldap/svc-account" + # decryptIfSecure: true + # usernameFieldName: "username" + # passwordFieldName: "password" +``` + +### AWS Credential Management + +**AWS Secrets Manager** (for JWT keys): +- **Fetched via**: `AwsSecretsUtils.fetchSecret()` +- **Location**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/utils/AwsSecretsUtils.scala` +- **Uses**: `DefaultCredentialsProvider` (AWS SDK standard credential chain) + - Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` + - IAM role (if running on EC2/ECS) + - AWS credentials file (~/.aws/credentials) +- **Secret Format**: JSON with fields configured via `privateKeyFieldName`, `publicKeyFieldName` +- **Version Staging**: Supports "AWSCURRENT" and "AWSPREVIOUS" version stages + +**AWS Systems Manager Parameter Store** (for service account credentials): +- **Utility**: `AwsSsmUtils` (not shown in current files, but referenced in config) +- **Enables**: Secure parameter retrieval with optional decryption + +### Validation Framework +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/validation/` + +All config classes implement `ConfigValidatable` trait: +- `validate(): ConfigValidationResult` (returns Success or Error) +- `throwErrors()` - throws ConfigValidationException on failure +- Validation called at startup via `ConfigProvider` + +--- + +## 7. DATA MODELS FOR USERS & SESSIONS + +### User Model +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/model/User.scala` + +```scala +case class User( + name: String, // Username + groups: Seq[String], // Assigned groups/roles + optionalAttributes: Map[String, Option[AnyRef]] // Key-value pairs (email, displayname, etc.) +) { + def filterGroupsByPrefixes(prefixes: Set[String], caseSensitive: Boolean): User = { + // Filters groups by prefix (for JWT claims filtering) + } +} +``` + +### User Configuration +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala` + +```scala +case class UserConfig( + username: String, + password: String, // PLAINTEXT in config (security consideration) + groups: Array[String], + attributes: Option[Map[String, String]] +) + +case class UsersConfig( + knownUsers: Array[UserConfig], + order: Int +) +``` + +### LDAP Configuration +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala` + +```scala +case class ActiveDirectoryLDAPConfig( + domain: String, + url: String, + searchFilter: String, + order: Int, + serviceAccount: ServiceAccountConfig, + enableKerberos: Option[KerberosConfig], + ldapRetry: Option[LdapRetryConfig], + attributes: Option[Map[String, String]] // LDAP field -> JWT claim mapping +) + +case class ServiceAccountConfig( + accountPattern: String, // CN=%s,OU=...,DC=... + inConfigAccount: Option[InConfigAccountConfig], + awsSecretsManagerAccount: Option[AwsSecretsLdapUserConfig], + awsSystemsManagerAccount: Option[AwsSystemsManagerLdapUserConfig] +) +``` + +### Spring Security Integration +- **Principal**: `User` (stored in `Authentication.getPrincipal()`) +- **Authorities**: Groups mapped to `SimpleGrantedAuthority` +- **Session**: STATELESS (no server-side session storage) +- **Token in JWT**: All user info persisted in JWT claims + +### User Repositories (for User Lookup) +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/` + +**Interface**: `UserRepository.searchForUser(username: String): Option[User]` + +**Implementations**: +1. `UsersFromConfigRepository` - Queries hardcoded users +2. `LdapUserRepository` - Queries LDAP directory +3. `DefaultUserRepositories` - Combines both, tries in order + +**Used for**: Refreshing user info during token refresh (verify user still exists, get updated groups) + +--- + +## 8. DEPENDENCIES DECLARATION + +### Build File +**Location**: `/Users/ab006hm/Projects/login-service/build.sbt` + +**Structure**: +```scala +lazy val parent = (project in file(".")) + .aggregate(api, clientLibrary, examples) + +lazy val api = project + .settings(libraryDependencies ++= apiDependencies) + .enablePlugins(TomcatPlugin, AutomateHeaderPlugin, FilteredJacocoAgentPlugin) + +lazy val clientLibrary = project + .settings(libraryDependencies ++= clientLibDependencies) + .enablePlugins(AutomateHeaderPlugin, FilteredJacocoAgentPlugin) + +lazy val examples = project + .dependsOn(clientLibrary) + .settings(libraryDependencies ++= exampleDependencies) +``` + +### Dependency Definitions +**File**: `/Users/ab006hm/Projects/login-service/project/Dependencies.scala` (149 lines) + +**Key Dependencies**: + +| Component | Library | Version | +|-----------|---------|---------| +| **Web** | spring-boot-starter-web | 2.7.8 | +| **Security** | spring-boot-starter-security | 2.7.8 | +| **LDAP** | spring-security-ldap | 5.7.6 | +| **Kerberos** | spring-security-kerberos-web/client | 1.0.1.RELEASE | +| **JWT Signing** | jjwt-api/impl/jackson | 0.11.5 | +| **Key Management** | nimbus-jose-jwt | 9.31 | +| **JWT Decoding** | spring-security-oauth2-jose | 5.7.6 | +| **AWS** | awssdk-secretsmanager, awssdk-ssm, awssdk-sts | 2.20.x | +| **Config** | pureconfig, pureconfig-yaml | 0.17.2 | +| **API Docs** | springdoc-openapi-ui | 1.6.14 | +| **Scala** | jackson-module-scala, scala-java8-compat | 2.14.2 / 0.9.0 | + +**Test Dependencies**: +- scalatest 3.2.15 +- spring-boot-starter-test 2.7.8 +- spring-security-test 5.7.6 +- scalamock 5.2.0 + +--- + +## 9. TEST PATTERNS - HOW AUTH FLOWS ARE TESTED + +### JWT Service Tests +**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala` + +**Test Class**: `JWTServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers` + +**Key Test Cases**: +```scala +// Test data setup +private val userWithoutEmailAndGroups: User = User( + name = "user2", + groups = Seq.empty, + optionalAttributes = Map.empty +) + +// Access token generation & validation +it should "return an access JWT that is verifiable by `publicKey`" in { + val jwt = jwtService.generateAccessToken(userWithoutGroups) + val parsedJWT = parseJWT(jwt) + assert(parsedJWT.isSuccess) +} + +// Token claims validation +it should "return an access JWT with subject equal to User.name and has type access" in { + val jwt = jwtService.generateAccessToken(userWithoutGroups) + parsedJWT.map(_.getBody.getSubject) shouldBe userWithoutGroups.name + parsedJWT.map(_.getBody.get("type", classOf[String])) shouldBe "access" +} + +// Custom attributes in token +it should "return an access JWT with email claim equal to User.email if it is not None" +``` + +**Helper Method**: +```scala +private def parseJWT(jwt: Token, publicKey: PublicKey = jwtService.publicKeys._1): Try[Jws[Claims]] = Try { + Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(jwt.token) +} +``` + +### Token Controller Tests +**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/controller/TokenControllerTest.scala` + +**Test Class**: `TokenControllerTest extends AnyFlatSpec with ControllerIntegrationTestBase` + +**Setup**: +```scala +@WebMvcTest(controllers = Array(classOf[TokenController])) +@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler], classOf[AuthManagerConfig])) + +@MockBean private var jwtService: JWTService = _ +``` + +**Test Cases**: +```scala +// Basic token generation with mocked service +it should "return tokens generated by mocked JWTService for the basic-auth authenticated user" in { + when(jwtService.generateAccessToken(FakeAuthentication.fakeUser)).thenReturn(fakeAccessJwt) + when(jwtService.generateRefreshToken(FakeAuthentication.fakeUser)).thenReturn(fakeRefreshJwt) + + mockMvc.perform( + post("/token/generate") + .`with`(authentication(FakeAuthentication.fakeUserAuthentication)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status.isOk) + .andExpect(content.json(expectedJsonBody)) +} + +// Group prefix filtering +it should "return tokens... with group-prefixes (single)" in { + // Tests group filtering functionality +} +``` + +### Authentication Provider Tests +**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/provider/ConfigUsersAuthenticationProviderTest.scala` + +**Pattern**: Spring Security's `AuthenticationProvider` testing + +```scala +// Test valid credentials +// Test invalid credentials +// Test missing user +// Test group assignment +``` + +### LDAP Provider Tests +**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProviderTest.scala` + +- Uses test LDAP fixtures/mocks +- Tests domain handling, retry logic, attribute mapping + +### Configuration Tests +**Location**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/config/` + +- `UsersConfigTest.scala` - Validates user config parsing +- `ActiveDirectoryLDAPConfigTest.scala` - Validates LDAP config +- `KerberosConfigTest.scala` - Validates Kerberos config +- `AwsSecretsLdapUserConfigTest.scala` - Tests AWS secret config +- `InMemoryKeyConfigTest.scala` - Tests in-memory key config +- `AwsSecretsManagerKeyConfigTest.scala` - Tests AWS secret key config + +**Test Resource Config**: `/Users/ab006hm/Projects/login-service/api/src/test/resources/application.yaml` + +--- + +## 10. MIDDLEWARE & FILTER CHAINS + +### Security Filter Chain +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala` + +**Filter Order** (executed top to bottom): + +1. **Kerberos SPNEGO Filter** (if enabled) + - Class: `SpnegoAuthenticationProcessingFilter` + - Configured in: `KerberosSPNEGOAuthenticationProvider.spnegoAuthenticationProcessingFilter` + - Processes: Negotiate header tokens + - Validates: Kerberos tickets against keytab + +2. **Basic Auth Filter** + - Class: `BasicAuthenticationFilter` + - Processes: Authorization: Basic header + - Parses: Base64-encoded username:password + - Creates: `UsernamePasswordAuthenticationToken` + +3. **Authentication Manager** + - Delegates to ordered `AuthenticationProvider` list + - Each provider tries in sequence + - First successful auth wins + +4. **Authorization Filter** (Spring Security) + - Enforces path-based authorization rules + - Public paths: API docs, swagger, actuator, public keys, token refresh + - Protected paths: Require authentication (`/token/generate`) + +### Exception Handling +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/RestResponseEntityExceptionHandler.scala` + +**Custom Auth Entry Point** (in SecurityConfig): +```scala +private def customAuthenticationEntryPoint: AuthenticationEntryPoint = + (request, response, authException) => { + authException match { + case LdapConnectionException(msg, _) => + response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT) // 504 + response.write(s"""{"error": "LDAP connection failed: $msg"}""") + case _ => + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) // 401 + response.write(s"""{"error": "User login error"}""") + } + } +``` + +**Special Error Handling**: +- LDAP connection failures → 504 Gateway Timeout +- Auth failures → 401 Unauthorized +- Expired JWT → 401 Unauthorized +- Malformed JWT → 400 Bad Request + +### CORS & CSRF Configuration +- **CSRF**: Disabled (API is stateless, uses tokens) +- **CORS**: Enabled (allows cross-origin requests) + +### Session Management +- **Policy**: `SessionCreationPolicy.STATELESS` +- **Effect**: No `JSESSIONID` cookies, no server-side session data +- **Token Storage**: JWT tokens sent via Authorization header or request body + +### MVC Configuration +**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/WebMvcConfig.scala` + +- Likely configures JSON serialization, CORS details, etc. + +--- + +## SUMMARY TABLE: Key Files by Feature + +| Feature | File Path | Key Classes | LOC | +|---------|-----------|-------------|-----| +| **JWT Generation** | `.../service/jwt/JWTService.scala` | `JWTService` | 323 | +| **Token Endpoints** | `.../controller/TokenController.scala` | `TokenController` | 261 | +| **Config Users Auth** | `.../provider/ConfigUsersAuthenticationProvider.scala` | `ConfigUsersAuthenticationProvider` | 60 | +| **LDAP Auth** | `.../provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProvider.scala` | `ActiveDirectoryLDAPAuthenticationProvider` | 160 | +| **Kerberos Auth** | `.../provider/kerberos/KerberosSPNEGOAuthenticationProvider.scala` | `KerberosSPNEGOAuthenticationProvider` | 73 | +| **Security Config** | `.../SecurityConfig.scala` | `SecurityConfig` | 102 | +| **Auth Manager** | `.../AuthManagerConfig.scala` | `AuthManagerConfig` | 71 | +| **Configuration** | `.../config/provider/ConfigProvider.scala` | `ConfigProvider` | 107 | +| **User Model** | `.../model/User.scala` | `User` | 31 | +| **Token Models** | `.../model/TokensWrapper.scala` | `TokensWrapper`, `AccessToken`, `RefreshToken` | 59 | +| **In-Memory Keys** | `.../config/jwt/InMemoryKeyConfig.scala` | `InMemoryKeyConfig` | 61 | +| **AWS Secret Keys** | `.../config/jwt/AwsSecretsManagerKeyConfig.scala` | `AwsSecretsManagerKeyConfig` | 160 | +| **AWS Utils** | `.../utils/AwsSecretsUtils.scala` | `AwsSecretsUtils` | 80 | + +--- + +## DESIGN PATTERNS OBSERVED + +1. **Provider Pattern**: Multiple auth providers with pluggable architecture +2. **Strategy Pattern**: Swappable key configs (in-memory vs AWS) +3. **Factory Pattern**: `AuthManagerConfig` creates providers based on config +4. **Repository Pattern**: `UserRepository` for user lookup abstraction +5. **Decorator Pattern**: `LdapUserDetailsContextMapperWithOptions` wraps base mapper +6. **Configuration Validation**: Custom trait-based validation with result merging +7. **Lazy Evaluation**: User repositories tried lazily via Iterator +8. **Scheduled Tasks**: `ScheduledThreadPoolExecutor` for key rotation + diff --git a/ENTRA_INTEGRATION_GUIDE.md b/ENTRA_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..467a9e3 --- /dev/null +++ b/ENTRA_INTEGRATION_GUIDE.md @@ -0,0 +1,635 @@ +# MS Entra (Azure AD) Token Exchange Integration Design Guide + +Based on the login-service codebase analysis, this guide provides a concrete blueprint for adding MS Entra support. + +## ARCHITECTURE OVERVIEW + +### Design Approach +Add MS Entra as a **fourth authentication provider** following the existing pluggable architecture: + +``` +Authentication Flow: + Client → REST API → AuthenticationManager → [Providers in order] + 1. ConfigUsers (if order=1) + 2. LDAP (if order=2) + 3. Kerberos (if order=3) + 4. MS Entra (if order=4) [NEW] +``` + +--- + +## IMPLEMENTATION PLAN + +### Phase 1: Configuration Classes + +**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala` + +```scala +package za.co.absa.loginsvc.rest.config.auth + +import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigValidationException, ConfigValidationResult} +import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess} + +case class MsEntraConfig( + order: Int, + tenantId: String, // Azure tenant ID / directory ID + clientId: String, // Application (client) ID + clientSecret: String, // Client secret (from config or AWS Secrets) + redirectUri: String, // Must match registered redirect URI in Azure + discoveryUrl: Option[String] = None, // Override for testing (defaults to Microsoft standard) + scope: String = "https://graph.microsoft.com/.default", + attributes: Option[Map[String, String]] = None // MS Graph -> JWT claim mapping +) 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"))), + + Option(clientSecret) + .map(_ => ConfigValidationSuccess) + .getOrElse(ConfigValidationError(ConfigValidationException("clientSecret is empty"))), + + Option(redirectUri) + .map(_ => ConfigValidationSuccess) + .getOrElse(ConfigValidationError(ConfigValidationException("redirectUri is empty"))) + ) + + results.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge) + } else ConfigValidationSuccess + } +} + +// Optional: AWS Secrets Manager variant for clientSecret +case class MsEntraAwsSecretsConfig( + order: Int, + tenantId: String, + clientId: String, + awsSecretsManagerConfig: AwsSecretReference, // secret name, region, field name + redirectUri: String, + discoveryUrl: Option[String] = None, + scope: String = "https://graph.microsoft.com/.default", + attributes: Option[Map[String, String]] = None +) extends ConfigValidatable with ConfigOrdering { ... } + +case class AwsSecretReference( + secretName: String, + region: String, + clientSecretFieldName: String +) +``` + +--- + +### Phase 2: HTTP Client & Token Exchange + +**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraClient.scala` + +```scala +package za.co.absa.loginsvc.rest.provider.entra + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig +import scala.io.Source +import scala.util.{Try, Using} + +class MsEntraClient(config: MsEntraConfig, objectMapper: ObjectMapper) { + + private val logger = LoggerFactory.getLogger(classOf[MsEntraClient]) + + /** + * Exchange authorization code for access token (OAuth2 token endpoint) + * Called after user authenticates via MS Entra login page + */ + def exchangeCodeForToken(authorizationCode: String): MsEntraTokenResponse = { + val tokenUrl = s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" + + val params = Map( + "client_id" -> config.clientId, + "client_secret" -> config.clientSecret, + "code" -> authorizationCode, + "redirect_uri" -> config.redirectUri, + "grant_type" -> "authorization_code", + "scope" -> config.scope + ) + + val response = postRequest(tokenUrl, params) + parseTokenResponse(response) + } + + /** + * Refresh access token using refresh token + */ + def refreshAccessToken(refreshToken: String): MsEntraTokenResponse = { + val tokenUrl = s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" + + val params = Map( + "client_id" -> config.clientId, + "client_secret" -> config.clientSecret, + "refresh_token" -> refreshToken, + "grant_type" -> "refresh_token", + "scope" -> config.scope + ) + + postRequest(tokenUrl, params).map(parseTokenResponse).get + } + + /** + * Call Microsoft Graph API to get user profile information + */ + def getUserInfo(accessToken: String): MsEntraUserInfo = { + val graphUrl = "https://graph.microsoft.com/v1.0/me" + + val response = getRequest(graphUrl, accessToken) + parseUserInfoResponse(response) + } + + private def postRequest(url: String, params: Map[String, String]): Try[String] = Try { + val connection = new java.net.URL(url).openConnection().asInstanceOf[java.net.HttpURLConnection] + connection.setRequestMethod("POST") + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + connection.setDoOutput(true) + + val body = params.map { case (k, v) => s"$k=${java.net.URLEncoder.encode(v, "UTF-8")}" } + .mkString("&") + + connection.getOutputStream.write(body.getBytes("UTF-8")) + + Using(Source.fromInputStream(connection.getInputStream))(_.mkString).get + } + + private def getRequest(url: String, accessToken: String): Try[String] = Try { + val connection = new java.net.URL(url).openConnection().asInstanceOf[java.net.HttpURLConnection] + connection.setRequestMethod("GET") + connection.setRequestProperty("Authorization", s"Bearer $accessToken") + + Using(Source.fromInputStream(connection.getInputStream))(_.mkString).get + } + + private def parseTokenResponse(jsonStr: String): MsEntraTokenResponse = { + val json: JsonNode = objectMapper.readTree(jsonStr) + MsEntraTokenResponse( + accessToken = json.get("access_token").asText(), + refreshToken = Option(json.get("refresh_token")).map(_.asText()), + expiresIn = json.get("expires_in").asInt(), + idToken = Option(json.get("id_token")).map(_.asText()) + ) + } + + private def parseUserInfoResponse(jsonStr: String): MsEntraUserInfo = { + val json: JsonNode = objectMapper.readTree(jsonStr) + MsEntraUserInfo( + userId = json.get("id").asText(), + userPrincipalName = json.get("userPrincipalName").asText(), + displayName = Option(json.get("displayName")).map(_.asText()), + mail = Option(json.get("mail")).map(_.asText()), + jobTitle = Option(json.get("jobTitle")).map(_.asText()) + ) + } +} + +case class MsEntraTokenResponse( + accessToken: String, + refreshToken: Option[String], + expiresIn: Int, + idToken: Option[String] +) + +case class MsEntraUserInfo( + userId: String, + userPrincipalName: String, + displayName: Option[String], + mail: Option[String], + jobTitle: Option[String] +) +``` + +--- + +### Phase 3: Authentication Provider + +**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraAuthenticationProvider.scala` + +```scala +package za.co.absa.loginsvc.rest.provider.entra + +import com.fasterxml.jackson.databind.ObjectMapper +import io.jsonwebtoken.Jwts +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException} +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import za.co.absa.loginsvc.model.User +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig + +import scala.collection.JavaConverters._ +import scala.util.Try + +/** + * Authenticates users via MS Entra (Azure AD) token exchange. + * + * Expected flow: + * 1. Client obtains authorization code from MS Entra login page + * 2. Client sends code to /token/generate via special header or param + * 3. This provider exchanges code for access token + * 4. Provider fetches user info from Microsoft Graph API + * 5. Provider creates User object with groups from Graph + * 6. JWTService then generates login-service JWT tokens + */ +class MsEntraAuthenticationProvider( + config: MsEntraConfig, + objectMapper: ObjectMapper +) extends AuthenticationProvider { + + private val logger = LoggerFactory.getLogger(classOf[MsEntraAuthenticationProvider]) + private val entraClient = new MsEntraClient(config, objectMapper) + + override def authenticate(authentication: Authentication): Authentication = { + val entraAuth = authentication.asInstanceOf[MsEntraAuthenticationToken] + val authorizationCode = entraAuth.getCredentials.toString + + logger.info(s"Authenticating via MS Entra with authorization code") + + try { + // Step 1: Exchange authorization code for access token + val tokenResponse = entraClient.exchangeCodeForToken(authorizationCode) + logger.debug(s"Received access token from MS Entra") + + // Step 2: Parse ID token (optional) to get basic user info + val idTokenClaims = tokenResponse.idToken.flatMap { token => + Try { + val claims = Jwts.parserBuilder() + .setSigningKey("") // ID tokens are typically validated against published keys + .build() + .parseClaimsJws(token) + .getBody + Option(claims) + }.toOption.flatten + } + + // Step 3: Fetch detailed user info from Microsoft Graph + val graphUserInfo = entraClient.getUserInfo(tokenResponse.accessToken) + logger.info(s"Retrieved user info for ${graphUserInfo.userPrincipalName}") + + // Step 4: Fetch user's group memberships from Microsoft Graph + val userGroups = fetchUserGroups(tokenResponse.accessToken, graphUserInfo.userId) + logger.debug(s"User ${graphUserInfo.userPrincipalName} has groups: $userGroups") + + // Step 5: Build User object + val userAttributes = Map( + "mail" -> graphUserInfo.mail, + "displayname" -> graphUserInfo.displayName, + "jobTitle" -> graphUserInfo.jobTitle + ).collect { case (k, Some(v)) => k -> Some(v) } + + val principal = User( + name = graphUserInfo.userPrincipalName, + groups = userGroups, + optionalAttributes = userAttributes + ) + + // Step 6: Return successful authentication + val token = new MsEntraAuthenticationToken(principal, authorizationCode) + token.setAuthenticated(true) + token.setDetails(Map( + "accessToken" -> tokenResponse.accessToken, + "refreshToken" -> tokenResponse.refreshToken.getOrElse(""), + "expiresIn" -> tokenResponse.expiresIn + ).asJava) + token + + } catch { + case e: Throwable => + logger.error(s"MS Entra authentication failed: ${e.getMessage}", e) + throw new BadCredentialsException("MS Entra authentication failed", e) + } + } + + override def supports(authentication: Class[_]): Boolean = + authentication == classOf[MsEntraAuthenticationToken] + + /** + * Fetch user's group memberships from Microsoft Graph + * Requires User.Read and Group.Read.All permissions + */ + private def fetchUserGroups(accessToken: String, userId: String): Seq[String] = { + try { + // Call: GET https://graph.microsoft.com/v1.0/me/memberOf + // Returns: List of group IDs, names, etc. + + // Placeholder: Actual implementation would use MsEntraClient or direct HTTP call + Seq("entra-user") // Minimum group + } catch { + case e: Throwable => + logger.warn(s"Failed to fetch user groups from MS Entra: ${e.getMessage}", e) + Seq.empty[String] + } + } +} + +/** + * Custom Authentication token for MS Entra + * Stores authorization code and later the access token + */ +class MsEntraAuthenticationToken( + principal: Any, + credentials: Any +) extends org.springframework.security.core.AbstractAuthenticationToken( + java.util.Collections.emptyList() +) { + setAuthenticated(false) + setPrincipal(principal) + setCredentials(credentials) + + override def getCredentials: AnyRef = credentials.asInstanceOf[AnyRef] + override def getPrincipal: AnyRef = principal.asInstanceOf[AnyRef] +} +``` + +--- + +### Phase 4: New REST Endpoints + +**File**: Modify `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/controller/TokenController.scala` + +Add new endpoint for MS Entra token exchange: + +```scala +@PostMapping( + path = Array("/generate-entra"), + produces = Array(MediaType.APPLICATION_JSON_VALUE) +) +@ResponseStatus(HttpStatus.OK) +@Operation( + summary = "Generate tokens via MS Entra (Azure AD) token exchange", + description = "Exchanges MS Entra authorization code for login-service JWT tokens" +) +def generateTokenEntra( + @RequestHeader("X-Entra-Code") entraAuthCode: String, + @RequestParam("group-prefixes") groupPrefixes: Optional[String], + @RequestParam(name = "case-sensitive", defaultValue = "false") caseSensitive: Boolean +): TokensWrapper = { + + // Create MS Entra authentication token from authorization code + val entraToken = new MsEntraAuthenticationToken(null, entraAuthCode) + + // Let AuthenticationManager handle it (will use MsEntraAuthenticationProvider) + val authentication = authManager.authenticate(entraToken) + + val user: User = authentication.getPrincipal match { + case u: User => u + case _ => throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Failed to extract user from MS Entra") + } + + // Apply group filtering if requested + val filteredGroupsUser = user.applyIfDefined(groupPrefixes.toScalaOption) { (u: User, prefixesStr: String) => + val prefixes = prefixesStr.trim.split(',') + u.filterGroupsByPrefixes(prefixes.toSet, caseSensitive) + } + + // Generate login-service tokens + val accessJwt = jwtService.generateAccessToken(filteredGroupsUser) + val refreshJwt = jwtService.generateRefreshToken(filteredGroupsUser) + TokensWrapper.fromTokens(accessJwt, refreshJwt) +} +``` + +--- + +### Phase 5: Configuration Update + +**Update YAML Config** (example.application.yaml): + +```yaml +loginsvc: + rest: + auth: + provider: + users: + order: 1 + known-users: [...] + + ldap: + order: 2 + # ... existing LDAP config + + ms-entra: # NEW + order: 3 + tenantId: "${ENTRA_TENANT_ID}" # From environment or secrets + clientId: "${ENTRA_CLIENT_ID}" + clientSecret: "${ENTRA_CLIENT_SECRET}" + redirectUri: "https://myservice/callback" + # OR use AWS Secrets: + # awsSecretsManagerConfig: + # secretName: "entra-credentials" + # region: "us-east-1" + # clientSecretFieldName: "client-secret" + + scope: "https://graph.microsoft.com/.default" + attributes: + mail: "email" + displayName: "display_name" + jobTitle: "job_title" +``` + +--- + +### Phase 6: Integration with AuthManagerConfig + +**Update**: `api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala` + +```scala +private def createAuthProviders(configs: Array[ConfigOrdering]): Array[AuthenticationProvider] = { + Array.empty[AuthenticationProvider] ++ configs.filter(_.order > 0).sortBy(_.order) + .map { + case c: UsersConfig => new ConfigUsersAuthenticationProvider(c) + case c: ActiveDirectoryLDAPConfig => new ActiveDirectoryLDAPAuthenticationProvider(c) + case c: MsEntraConfig => new MsEntraAuthenticationProvider(c, objectMapper) // NEW + case other => throw new IllegalStateException(s"unsupported config $other") + } +} +``` + +--- + +### Phase 7: Update ConfigProvider + +**Update**: `api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala` + +```scala +def getMsEntraConfig: Option[MsEntraConfig] = { + val msEntraConfigOption = createConfigClass[MsEntraConfig]("loginsvc.rest.auth.provider.ms-entra") + if (msEntraConfigOption.nonEmpty) + msEntraConfigOption.get.throwErrors() + msEntraConfigOption +} + +// Add to AuthManagerConfig initialization +``` + +--- + +## TESTING STRATEGY + +### Unit Tests + +**New File**: `api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraAuthenticationProviderTest.scala` + +```scala +class MsEntraAuthenticationProviderTest extends AnyFlatSpec with Matchers { + + it should "exchange authorization code for access token" in { + // Mock MsEntraClient + val mockClient = mock[MsEntraClient] + when(mockClient.exchangeCodeForToken("test-code")).thenReturn( + MsEntraTokenResponse("access-token-123", Some("refresh-token"), 3600, None) + ) + + // Test token exchange + } + + it should "create User object with MS Entra user info" in { + // Verify user is created with correct fields from Graph API + } + + it should "handle MS Entra errors gracefully" in { + // Test BadCredentialsException on auth failure + } +} +``` + +### Integration Tests + +```scala +@WebMvcTest(controllers = Array(classOf[TokenController])) +class MsEntraTokenControllerTest extends AnyFlatSpec { + + it should "generate tokens via MS Entra code exchange" in { + mockMvc.perform( + post("/token/generate-entra") + .header("X-Entra-Code", "auth-code-123") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status.isOk) + .andExpect(jsonPath("$.token").exists()) + .andExpect(jsonPath("$.refresh").exists()) + } +} +``` + +--- + +## SECURITY CONSIDERATIONS + +1. **Client Secret Management**: + - Never hardcode in YAML (use environment variables or AWS Secrets) + - Rotate regularly + - Use AWS Secrets Manager with version staging + +2. **Authorization Code**: + - Must be exchanged immediately (short-lived, ~10 minutes) + - Should include PKCE (Proof Key for Code Exchange) for public clients + - Prevent authorization code interception via HTTPS only + +3. **Token Validation**: + - Validate ID token signature against Azure's published keys + - Validate `aud` (audience) claim matches `clientId` + - Check `iss` (issuer) matches tenant + +4. **Refresh Tokens**: + - Store securely (not in localStorage on client side) + - Use refresh token rotation if supported + - Include `refresh_token_expires_in` for lifecycle management + +5. **Scopes**: + - Request minimum necessary scopes (principle of least privilege) + - `https://graph.microsoft.com/.default` for all permissions + - Could restrict to `User.Read` if only basic info needed + +6. **HTTPS Only**: + - All Entra communication must use TLS + - Redirect URI must use HTTPS in production + +--- + +## MIGRATION PATH + +### Backward Compatibility +- All existing auth methods (config users, LDAP, Kerberos) continue to work unchanged +- New MS Entra provider added as **optional 4th provider** +- No breaking changes to existing endpoints + +### Gradual Rollout +1. Deploy with MS Entra disabled (`order: 0` or omitted) +2. Test with small user subset +3. Enable for specific groups/teams +4. Full rollout when ready + +--- + +## DEPENDENCIES TO ADD + +**Update** `project/Dependencies.scala`: + +```scala +// Microsoft Graph & OAuth2 +lazy val microsoftGraphJavaSDK = "com.microsoft.graph" % "microsoft-graph" % "5.35.0" +lazy val azureIdentity = "com.azure" % "azure-identity" % "1.8.2" +lazy val joseJwt = "com.nimbusds" % "nimbus-jose-jwt" % "9.31" // Already included + +// Or use lightweight HTTP client approach (already have jackson) +``` + +--- + +## KEY DIFFERENCES FROM LDAP INTEGRATION + +| Aspect | LDAP | MS Entra | +|--------|------|----------| +| **Protocol** | LDAP/LDAPS | OAuth2 + REST API | +| **Group Fetch** | LDAP query + service account | MS Graph API + access token | +| **Discovery** | Manual URL/domain config | Azure metadata discovery | +| **Token Exchange** | N/A (direct LDAP bind) | Code → Access Token → User Info | +| **Refresh** | No token refresh | Token refresh via refresh token | +| **Attributes** | LDAP attributes directly | MS Graph API fields | + +--- + +## EXAMPLE CLIENT USAGE + +```javascript +// 1. Redirect user to MS Entra login +const entraLoginUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid%20profile%20email`; + +// 2. User logs in, gets authorization code +// Azure redirects to: https://myapp/callback?code=ABC123&session_state=XYZ + +// 3. Exchange code for login-service tokens +const response = await fetch('/token/generate-entra', { + method: 'POST', + headers: { + 'X-Entra-Code': 'ABC123', + 'Content-Type': 'application/json' + } +}); + +const { token, refresh } = await response.json(); + +// 4. Use token for API requests +fetch('/api/protected', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); +``` + diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index 18092bc..acba452 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -98,6 +98,23 @@ 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" + # Expected value of the JWT 'aud' claim — typically "api://your-client-id" + #audience: "api://your-client-id" + # 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) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala index ead04ed..e204ff7 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala @@ -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} @@ -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 @@ -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() } diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala new file mode 100644 index 0000000..73badc4 --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -0,0 +1,61 @@ +/* + * 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 audience Expected value of the JWT 'aud' claim, e.g. "api://your-client-id" + * @param order Provider ordering (0 = disabled, 1+ = active) + * @param attributes Optional mapping from Entra JWT claim names to LS JWT claim names + */ +case class MsEntraConfig( + tenantId: String, + clientId: String, + audience: String, + order: Int, + attributes: Option[Map[String, String]] +) 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"))), + + Option(audience) + .map(_ => ConfigValidationSuccess) + .getOrElse(ConfigValidationError(ConfigValidationException("audience is empty"))) + ) + + results.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge) + } else ConfigValidationSuccess + } +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/AuthConfigProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/AuthConfigProvider.scala index cc0a067..86f91f4 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/AuthConfigProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/AuthConfigProvider.scala @@ -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] } diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala index aa871a6..71be7bd 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala @@ -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)) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala new file mode 100644 index 0000000..49755f0 --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala @@ -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 ` 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) + } +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala new file mode 100644 index 0000000..012f806 --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -0,0 +1,147 @@ +/* + * 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 com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.source.{JWKSource, RemoteJWKSet} +import com.nimbusds.jose.proc.{JWSVerificationKeySelector, SecurityContext => NimbusSecurityContext} +import com.nimbusds.jwt.proc.{BadJWTException, DefaultJWTClaimsVerifier, DefaultJWTProcessor} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import org.slf4j.LoggerFactory +import za.co.absa.loginsvc.model.User +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig + +import java.net.URL +import java.util.concurrent.TimeUnit +import scala.collection.JavaConverters._ +import scala.util.{Failure, Success, Try} + +/** + * Validates MS Entra (Azure AD) Bearer JWT tokens. + * + * Fetches the JWKS URI from Microsoft's OIDC discovery endpoint and caches it. + * Validates the token's signature, expiry, issuer and audience, then extracts a [[User]]. + * + * The discovery URL follows the Microsoft standard: + * https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration + * + * @param config Entra configuration + * @param jwkSourceOverride Optional override for the JWK source (used in tests to avoid HTTP calls) + */ +class MsEntraTokenValidator( + config: MsEntraConfig, + private[entra] val jwkSourceOverride: Option[JWKSource[NimbusSecurityContext]] = None +) { + + private val logger = LoggerFactory.getLogger(classOf[MsEntraTokenValidator]) + + private val discoveryUrl = + s"https://login.microsoftonline.com/${config.tenantId}/v2.0/.well-known/openid-configuration" + + private val expectedIssuer = + s"https://login.microsoftonline.com/${config.tenantId}/v2.0" + + // Cache the JWKSource keyed by jwks_uri string; refreshes after 1 hour + private val jwkSourceCache: LoadingCache[String, JWKSource[NimbusSecurityContext]] = + CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(new CacheLoader[String, JWKSource[NimbusSecurityContext]] { + override def load(jwksUri: String): JWKSource[NimbusSecurityContext] = { + logger.info(s"Loading JWKS from $jwksUri") + new RemoteJWKSet[NimbusSecurityContext](new URL(jwksUri)) + } + }) + + /** + * Validates the given raw Entra JWT string. + * + * @param rawToken the Bearer token string (without "Bearer " prefix) + * @return a [[User]] if the token is valid, or a Failure with a descriptive exception + */ + def validate(rawToken: String): Try[User] = { + Try { + val jwkSource = jwkSourceOverride.getOrElse { + val jwksUri = resolveJwksUri() + jwkSourceCache.get(jwksUri) + } + + val jwtProcessor = new DefaultJWTProcessor[NimbusSecurityContext]() + val keySelector = new JWSVerificationKeySelector[NimbusSecurityContext]( + JWSAlgorithm.RS256, + jwkSource + ) + jwtProcessor.setJWSKeySelector(keySelector) + + // Verify standard claims: iss, aud, exp, nbf + val requiredClaims = new DefaultJWTClaimsVerifier[NimbusSecurityContext]( + new JWTClaimsSet.Builder() + .issuer(expectedIssuer) + .audience(config.audience) + .build(), + Set("sub", "iat", "exp").asJava + ) + jwtProcessor.setJWTClaimsSetVerifier(requiredClaims) + + val claims: JWTClaimsSet = jwtProcessor.process(rawToken, null) + extractUser(claims) + } recoverWith { + case e: BadJWTException => + logger.warn(s"Entra token validation failed (claims): ${e.getMessage}") + Failure(e) + case e: Exception => + logger.warn(s"Entra token validation failed: ${e.getMessage}") + Failure(e) + } + } + + private def extractUser(claims: JWTClaimsSet): User = { + val username = Option(claims.getStringClaim("preferred_username")) + .orElse(Option(claims.getStringClaim("upn"))) + .orElse(Option(claims.getSubject)) + .getOrElse(throw new IllegalArgumentException("Entra token has no usable username claim (preferred_username/upn/sub)")) + + val groups: Seq[String] = Option(claims.getStringListClaim("groups")) + .map(_.asScala.toSeq) + .getOrElse(Seq.empty) + + val optionalAttributes: Map[String, Option[AnyRef]] = config.attributes.getOrElse(Map.empty).flatMap { + case (claimName, lsClaimName) => + Option(claims.getClaim(claimName)).map { value => + lsClaimName -> Some(value.asInstanceOf[AnyRef]) + } + } + + User(username, groups, optionalAttributes) + } + + private def resolveJwksUri(): String = { + val conn = new URL(discoveryUrl).openConnection() + conn.setConnectTimeout(5000) + conn.setReadTimeout(5000) + val json = scala.io.Source.fromInputStream(conn.getInputStream).mkString + // Simple string extraction without pulling in additional JSON libraries + val jwksUriPattern = """"jwks_uri"\s*:\s*"([^"]+)"""".r + jwksUriPattern.findFirstMatchIn(json) + .map(_.group(1)) + .getOrElse(throw new IllegalStateException(s"Could not find jwks_uri in OIDC discovery doc at $discoveryUrl")) + } +} + +object MsEntraTokenValidator { + def apply(config: MsEntraConfig): MsEntraTokenValidator = new MsEntraTokenValidator(config) +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala new file mode 100644 index 0000000..67b06ee --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala @@ -0,0 +1,79 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException +import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess} + +class MsEntraConfigTest extends AnyFlatSpec with Matchers { + + private val validConfig = MsEntraConfig( + tenantId = "test-tenant-id", + clientId = "test-client-id", + audience = "api://test-client-id", + order = 1, + attributes = Some(Map("preferred_username" -> "upn", "email" -> "email")) + ) + + "MsEntraConfig" should "validate expected filled content" in { + validConfig.validate() shouldBe ConfigValidationSuccess + } + + it should "validate with no attributes (they are optional)" in { + validConfig.copy(attributes = None).validate() shouldBe ConfigValidationSuccess + } + + it should "validate with empty attributes" in { + validConfig.copy(attributes = Some(Map.empty)).validate() shouldBe ConfigValidationSuccess + } + + it should "fail on null tenantId" in { + val result = validConfig.copy(tenantId = null).validate() + result shouldBe ConfigValidationError(ConfigValidationException("tenantId is empty")) + } + + it should "fail on null clientId" in { + val result = validConfig.copy(clientId = null).validate() + result shouldBe ConfigValidationError(ConfigValidationException("clientId is empty")) + } + + it should "fail on null audience" in { + val result = validConfig.copy(audience = null).validate() + result shouldBe ConfigValidationError(ConfigValidationException("audience is empty")) + } + + it should "accumulate multiple validation errors" in { + val result = validConfig.copy(tenantId = null, clientId = null).validate() + result shouldBe a[ConfigValidationError] + result.errors should have size 2 + result.errors.map(_.msg) should contain allOf ("tenantId is empty", "clientId is empty") + } + + it should "pass validation when disabled (order=0) even with null fields" in { + MsEntraConfig(tenantId = null, clientId = null, audience = null, order = 0, attributes = None) + .validate() shouldBe ConfigValidationSuccess + } + + it should "throw on throwErrors() when invalid" in { + val exception = intercept[ConfigValidationException] { + validConfig.copy(tenantId = null).throwErrors() + } + exception.msg should include("tenantId is empty") + } +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala new file mode 100644 index 0000000..10a3d19 --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala @@ -0,0 +1,140 @@ +/* + * 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.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.{mock, when} +import org.scalatest.BeforeAndAfterEach +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.springframework.mock.web.{MockFilterChain, MockHttpServletRequest, MockHttpServletResponse} +import org.springframework.security.core.context.SecurityContextHolder +import za.co.absa.loginsvc.model.User + +import javax.servlet.http.HttpServletResponse +import scala.util.{Failure, Success} + +class MsEntraBearerTokenFilterTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach { + + private val fakeUser = User("user@example.com", Seq("group1", "group2"), Map.empty) + + private val mockValidator = mock(classOf[MsEntraTokenValidator]) + private val filter = new MsEntraBearerTokenFilter(mockValidator) + + override def beforeEach(): Unit = { + SecurityContextHolder.clearContext() + } + + override def afterEach(): Unit = { + SecurityContextHolder.clearContext() + } + + "MsEntraBearerTokenFilter" should "authenticate and pass through on a valid Bearer token" in { + when(mockValidator.validate(anyString())).thenReturn(Success(fakeUser)) + + val request = new MockHttpServletRequest() + request.addHeader("Authorization", "Bearer valid.entra.token") + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + response.getStatus shouldBe HttpServletResponse.SC_OK + val auth = SecurityContextHolder.getContext.getAuthentication + auth should not be null + auth.getPrincipal shouldBe fakeUser + chain.getRequest should not be null // filter chain was called + } + + it should "return 401 and not call filter chain on an invalid Bearer token" in { + when(mockValidator.validate(anyString())).thenReturn(Failure(new Exception("Bad token"))) + + val request = new MockHttpServletRequest() + request.addHeader("Authorization", "Bearer invalid.token") + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + response.getStatus shouldBe HttpServletResponse.SC_UNAUTHORIZED + response.getContentType shouldBe "application/json" + response.getContentAsString should include("Invalid or expired Entra token") + chain.getRequest shouldBe null // filter chain was NOT called + } + + it should "pass through without calling the validator when no Authorization header is present" in { + val request = new MockHttpServletRequest() + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + response.getStatus shouldBe HttpServletResponse.SC_OK + SecurityContextHolder.getContext.getAuthentication shouldBe null + chain.getRequest should not be null + } + + it should "pass through without calling the validator when Authorization header is not a Bearer token" in { + val request = new MockHttpServletRequest() + request.addHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + response.getStatus shouldBe HttpServletResponse.SC_OK + SecurityContextHolder.getContext.getAuthentication shouldBe null + chain.getRequest should not be null + } + + it should "skip validation and pass through when SecurityContext is already authenticated" in { + when(mockValidator.validate(anyString())).thenReturn(Success(fakeUser)) + + // Pre-populate the security context as if another filter already authenticated + val preExistingAuth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + "already-authenticated-user", "creds", + new java.util.ArrayList[org.springframework.security.core.GrantedAuthority]() + ) + SecurityContextHolder.getContext.setAuthentication(preExistingAuth) + + val request = new MockHttpServletRequest() + request.addHeader("Authorization", "Bearer some.token") + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + // Authentication should remain the pre-existing one + SecurityContextHolder.getContext.getAuthentication.getPrincipal shouldBe "already-authenticated-user" + chain.getRequest should not be null + } + + it should "populate groups as Spring authorities" in { + when(mockValidator.validate(anyString())).thenReturn(Success(fakeUser)) + + val request = new MockHttpServletRequest() + request.addHeader("Authorization", "Bearer valid.entra.token") + val response = new MockHttpServletResponse() + val chain = new MockFilterChain() + + filter.doFilter(request, response, chain) + + import scala.collection.JavaConverters._ + val authorities = SecurityContextHolder.getContext.getAuthentication.getAuthorities.asScala.map(_.getAuthority) + authorities should contain allOf ("group1", "group2") + } +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala new file mode 100644 index 0000000..fa2bc2d --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala @@ -0,0 +1,189 @@ +/* + * 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 com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.jwk.{JWKSet, RSAKey} +import com.nimbusds.jose.proc.{SecurityContext => NimbusSecurityContext} +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig + +import java.security.KeyPairGenerator +import java.util.{Date, UUID} +import scala.collection.JavaConverters._ +import scala.util.Success + +class MsEntraTokenValidatorTest extends AnyFlatSpec with Matchers { + + private val tenantId = "test-tenant-id" + private val clientId = "test-client-id" + private val audience = "api://test-client-id" + private val issuer = s"https://login.microsoftonline.com/$tenantId/v2.0" + + private val config = MsEntraConfig( + tenantId = tenantId, + clientId = clientId, + audience = audience, + order = 1, + attributes = Some(Map("email" -> "email")) + ) + + // Generate a real RSA key pair for testing + private val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + private val keyPair = keyPairGenerator.generateKeyPair() + private val rsaJwk = new RSAKey.Builder(keyPair.getPublic.asInstanceOf[java.security.interfaces.RSAPublicKey]) + .privateKey(keyPair.getPrivate) + .keyID(UUID.randomUUID().toString) + .build() + + private val jwkSet = new JWKSet(rsaJwk) + private val jwkSource = new ImmutableJWKSet[NimbusSecurityContext](jwkSet) + + private val validator = new MsEntraTokenValidator(config, Some(jwkSource)) + + private def buildToken( + subject: String = "user-oid-123", + preferredUsername: String = "user@example.com", + groups: Seq[String] = Seq("group1", "group2"), + email: String = "user@example.com", + expiresInSeconds: Int = 3600, + issuerOverride: String = issuer, + audienceOverride: String = audience + ): String = { + val now = new Date() + val exp = new Date(now.getTime + expiresInSeconds * 1000L) + + val claims = new JWTClaimsSet.Builder() + .subject(subject) + .issuer(issuerOverride) + .audience(audienceOverride) + .issueTime(now) + .expirationTime(exp) + .claim("preferred_username", preferredUsername) + .claim("groups", groups.asJava) + .claim("email", email) + .build() + + val header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaJwk.getKeyID) + .build() + + val jwt = new SignedJWT(header, claims) + jwt.sign(new RSASSASigner(rsaJwk)) + jwt.serialize() + } + + "MsEntraTokenValidator" should "return a User for a valid token" in { + val token = buildToken() + val result = validator.validate(token) + + result shouldBe a[Success[_]] + val user = result.get + user.name shouldBe "user@example.com" + user.groups should contain theSameElementsAs Seq("group1", "group2") + } + + it should "map configured attribute claims to optional attributes" in { + val token = buildToken(email = "mapped@example.com") + val user = validator.validate(token).get + user.optionalAttributes.get("email") shouldBe Some(Some("mapped@example.com")) + } + + it should "use 'upn' claim as username when preferred_username is absent" in { + val now = new Date() + val exp = new Date(now.getTime + 3600 * 1000L) + val claims = new JWTClaimsSet.Builder() + .subject("sub-id") + .issuer(issuer) + .audience(audience) + .issueTime(now) + .expirationTime(exp) + .claim("upn", "upnuser@example.com") + .claim("groups", Seq.empty[String].asJava) + .build() + val jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(), claims) + jwt.sign(new RSASSASigner(rsaJwk)) + + val user = validator.validate(jwt.serialize()).get + user.name shouldBe "upnuser@example.com" + } + + it should "fall back to sub claim as username when neither preferred_username nor upn is present" in { + val now = new Date() + val exp = new Date(now.getTime + 3600 * 1000L) + val claims = new JWTClaimsSet.Builder() + .subject("sub-only-user") + .issuer(issuer) + .audience(audience) + .issueTime(now) + .expirationTime(exp) + .claim("groups", Seq.empty[String].asJava) + .build() + val jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(), claims) + jwt.sign(new RSASSASigner(rsaJwk)) + + val user = validator.validate(jwt.serialize()).get + user.name shouldBe "sub-only-user" + } + + it should "return a Failure for an expired token" in { + // Use -120s to exceed Nimbus's default 60s clock-skew tolerance + val token = buildToken(expiresInSeconds = -120) + val result = validator.validate(token) + result.isFailure shouldBe true + } + + it should "return a Failure for a token with wrong issuer" in { + val token = buildToken(issuerOverride = "https://evil.example.com") + val result = validator.validate(token) + result.isFailure shouldBe true + } + + it should "return a Failure for a token with wrong audience" in { + val token = buildToken(audienceOverride = "api://different-client") + val result = validator.validate(token) + result.isFailure shouldBe true + } + + it should "return a Failure for a malformed token string" in { + val result = validator.validate("not.a.valid.jwt") + result.isFailure shouldBe true + } + + it should "return empty groups when groups claim is absent" in { + val now = new Date() + val exp = new Date(now.getTime + 3600 * 1000L) + val claims = new JWTClaimsSet.Builder() + .subject("sub-id") + .issuer(issuer) + .audience(audience) + .issueTime(now) + .expirationTime(exp) + .claim("preferred_username", "nogroups@example.com") + .build() + val jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(), claims) + jwt.sign(new RSASSASigner(rsaJwk)) + + val user = validator.validate(jwt.serialize()).get + user.groups shouldBe empty + } +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/actuator/LdapHealthServiceTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/actuator/LdapHealthServiceTest.scala index 8a7cfcc..fc2c475 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/service/actuator/LdapHealthServiceTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/actuator/LdapHealthServiceTest.scala @@ -20,7 +20,7 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.springframework.boot.actuate.health.Health import org.springframework.boot.test.context.SpringBootTest -import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, LdapUserCredentialsConfig, ServiceAccountConfig, UsersConfig} +import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, LdapUserCredentialsConfig, MsEntraConfig, ServiceAccountConfig, UsersConfig} import za.co.absa.loginsvc.rest.config.provider.AuthConfigProvider import javax.naming.CommunicationException @@ -55,8 +55,8 @@ class LdapHealthServiceTest extends AnyFlatSpec with Matchers { "LdapHealthService" should "Return Up on Order 0" in { val configProvider = new AuthConfigProvider { override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = Some(ldapCfgZeroOrder) - override def getUsersConfig: Option[UsersConfig] = None + override def getMsEntraConfig: Option[MsEntraConfig] = None } val ldapHealthService: LdapHealthService = new testLdapHealthService(configProvider) val health = ldapHealthService.health() @@ -67,8 +67,8 @@ class LdapHealthServiceTest extends AnyFlatSpec with Matchers { "LdapHealthService" should "Return Up when ActiveDirectoryLDAPConfig is None" in { val configProvider = new AuthConfigProvider { override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = None - override def getUsersConfig: Option[UsersConfig] = None + override def getMsEntraConfig: Option[MsEntraConfig] = None } val ldapHealthService: LdapHealthService = new testLdapHealthService(configProvider) val health = ldapHealthService.health() @@ -79,8 +79,8 @@ class LdapHealthServiceTest extends AnyFlatSpec with Matchers { "LdapHealthService" should "Return Down when Ldap connection fails" in { val configProvider = new AuthConfigProvider { override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = Some(ldapCfgZeroOrder.copy(order = 2)) - override def getUsersConfig: Option[UsersConfig] = None + override def getMsEntraConfig: Option[MsEntraConfig] = None } val ldapHealthService: LdapHealthService = new testLdapHealthService(configProvider) val health = ldapHealthService.health() diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala index 5ca7c0a..eb0cb90 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/service/search/DefaultUserRepositoriesTest.scala @@ -20,7 +20,7 @@ import org.scalamock.scalatest.MockFactory import org.scalatest.BeforeAndAfterEach import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers -import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, LdapUserCredentialsConfig, ServiceAccountConfig, UserConfig, UsersConfig} +import za.co.absa.loginsvc.rest.config.auth.{ActiveDirectoryLDAPConfig, LdapUserCredentialsConfig, MsEntraConfig, ServiceAccountConfig, UserConfig, UsersConfig} import za.co.absa.loginsvc.rest.config.provider.{AuthConfigProvider, ConfigProvider} import za.co.absa.loginsvc.rest.config.validation.ConfigValidationException @@ -39,6 +39,7 @@ class DefaultUserRepositoriesTest extends AnyFlatSpec with BeforeAndAfterEach wi new AuthConfigProvider { override def getLdapConfig: Option[ActiveDirectoryLDAPConfig] = optLdapConfig override def getUsersConfig: Option[UsersConfig] = optUsersConfig + override def getMsEntraConfig: Option[MsEntraConfig] = None } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 9a6f0a4..c9cef47 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -74,6 +74,9 @@ object Dependencies { // Enables /actuator/health endpoint lazy val springBootStarterActuator = "org.springframework.boot" % "spring-boot-starter-actuator" % Versions.springBoot + // guava Cache for JWKS caching in Entra token validation + lazy val cacheBuilderApi = "com.google.guava" % "guava" % "33.0.0-jre" + lazy val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalatest % Test lazy val springBootTest = "org.springframework.boot" % "spring-boot-starter-test" % Versions.springBoot % Test lazy val springBootSecurityTest = "org.springframework.security" % "spring-security-test" % Versions.spring % Test @@ -111,6 +114,8 @@ object Dependencies { springBootStarterActuator, + cacheBuilderApi, + scalaTest, springBootTest, springBootSecurityTest, From 2659d9a5825c77e9a256e58a7bea88ccf36d0464 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 11 Mar 2026 10:58:20 +0100 Subject: [PATCH 2/9] chore: remove planning and AI context files --- .github/copilot-instructions.md | 131 ------ CODEBASE_ANALYSIS.md | 753 -------------------------------- ENTRA_INTEGRATION_GUIDE.md | 635 --------------------------- 3 files changed, 1519 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 CODEBASE_ANALYSIS.md delete mode 100644 ENTRA_INTEGRATION_GUIDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 8746fc3..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,131 +0,0 @@ -# Copilot Instructions — login-service - -## Build & Test - -```bash -# Compile everything (cross-builds clientLibrary for 2.12 + 2.13) -sbt +compile - -# Run all tests -sbt +test - -# Run tests for a single module -sbt "api / test" -sbt "clientLibrary / test" - -# Run a single test class -sbt "api / testOnly za.co.absa.loginsvc.rest.controller.TokenControllerTest" - -# Run a single test by name fragment -sbt "api / testOnly *TokenControllerTest -- -z \"return tokens\"" - -# Code coverage (JaCoCo) — runs clean + test + report across all modules -sbt jacoco -# Reports at: {module}/target/jacoco/report/index.html - -# Start Tomcat locally (builds the WAR first) -sbt "api / Tomcat / start" -# Requires: --spring.config.location=api/src/main/resources/example.application.yaml -# or env: SPRING_CONFIG_LOCATION=api/src/main/resources/example.application.yaml - -# Generate cross-compiled docs -sbt +doc -``` - -CI runs: `sbt +test +doc` (see `.github/workflows/build.yml`). - -## Architecture - -### Module Layout - -- **`api`** — The service itself. Spring Boot 2.7 app deployed as a WAR on Tomcat (via `xsbt-web-plugin`). Scala 2.12 only. -- **`clientLibrary`** — Standalone JWT verification library for consumers. Cross-compiled for Scala 2.12 and 2.13. Published to Maven. -- **`examples`** — Usage examples; depends on `clientLibrary`. - -### Authentication Flow - -The service issues RS256-signed JWTs. Clients authenticate via one of several pluggable providers, then receive access + refresh tokens. - -``` -Client request (Basic Auth / SPNEGO / Bearer) - → Spring Security filter chain - → AuthenticationManager (ProviderManager with ordered providers) - → Provider authenticates, returns Authentication with User principal - → TokenController.generateToken(authentication) - → JWTService.generateAccessToken / generateRefreshToken - → Response: { "token": "...", "refresh": "..." } -``` - -**Pluggable provider system:** -- Providers are enabled/disabled and ordered via the `order` field in YAML config (`0` = disabled, `1+` = active, lower = higher priority). -- `AuthManagerConfig` builds the `ProviderManager` from `AuthConfigProvider` results. -- `DefaultUserRepositories` builds a parallel ordered list of `UserRepository` implementations for user lookup during token refresh. -- When adding a new auth provider, you must wire it into: `AuthConfigProvider` trait, `ConfigProvider`, `AuthManagerConfig`, `SecurityConfig`, and `DefaultUserRepositories`. - -**Existing providers:** -| Provider | Config Class | Auth Mechanism | -|---|---|---| -| Config Users | `UsersConfig` | Username/password from YAML | -| AD LDAP | `ActiveDirectoryLDAPConfig` | LDAP bind against Active Directory | -| Kerberos SPNEGO | `KerberosConfig` (nested in LDAP) | SPNEGO filter before BasicAuthFilter | - -### Configuration System - -All config is read from a single YAML file (path via `spring.config.location`) using **PureConfig** — not Spring's property binding. - -- `ConfigProvider` reads the YAML with `YamlConfigSource` and exposes typed config via traits: `AuthConfigProvider`, `JwtConfigProvider`, `ExperimentalRestConfigProvider`. -- Config classes live under `config/auth/` and implement `ConfigValidatable` (custom validation) + `ConfigOrdering` (the `order: Int` trait). -- Validation uses `ConfigValidationResult` (a sealed trait with `Success`/`Error` variants that merge via `foldLeft`). Call `throwErrors()` to fail fast on startup. - -### JWT Key Management - -Two mutually exclusive key strategies (configured under `loginsvc.rest.jwt`): -- `generate-in-memory` — RSA key pair generated at startup, with optional scheduled rotation/layover/phase-out. -- `aws-secrets-manager` — Fetches RSA keys from AWS Secrets Manager with periodic polling. - -`JWTService` handles generation, signing, refresh, and key rotation. It exposes keys as both raw `PublicKey` and `JWKSet`. - -### Token Refresh - -`JWTService.refreshTokens` validates both old access and refresh tokens, then calls `UserSearchService.searchUser(username)` to verify the user still exists and re-fetch their current groups. This means every auth provider should have a corresponding `UserRepository` implementation for refresh to work. - -## Conventions - -### New Auth Provider Checklist - -1. Config case class in `config/auth/` — extend `ConfigValidatable` with `ConfigOrdering` -2. Add to `AuthConfigProvider` trait and implement in `ConfigProvider` -3. `AuthenticationProvider` impl in `provider/` — return `UsernamePasswordAuthenticationToken` with `User` principal -4. Register in `AuthManagerConfig.createAuthProviders` pattern match -5. `UserRepository` impl in `service/search/` for token refresh support -6. Register in `DefaultUserRepositories.createUserRepositories` pattern match -7. Wire filter (if non-Basic-Auth) in `SecurityConfig.filterChain` -8. Add example config block to `example.application.yaml` -9. Add test coverage following existing patterns - -### File Headers - -All source files require the Apache 2.0 license header (enforced by `sbt-header` plugin). The header is auto-managed — don't add it manually; run `sbt headerCreate` if needed. - -### User Model - -`User(name: String, groups: Seq[String], optionalAttributes: Map[String, Option[AnyRef]])` is the universal principal. All providers must produce a `User` instance. The `optionalAttributes` map carries extra claims (e.g., `email`, `displayname`) that get embedded in the access JWT. - -### Test Patterns - -- Unit tests use **ScalaTest** (`AnyFlatSpec` style) with **ScalaMock**. -- Controller integration tests extend `ControllerIntegrationTestBase` (bridges Spring's `TestContextManager` with ScalaTest lifecycle). Use `@WebMvcTest` + `MockMvc`. -- Test config: `api/src/test/resources/application.yaml` (uses in-memory keys, config-based users, LDAP disabled). -- Mock the `JWTService` in controller tests; test providers directly with their config classes. - -### Scala Specifics - -- Scala 2.12 for `api` module; use `scala.collection.JavaConverters._` (not `scala.jdk.CollectionConverters`). -- `clientLibrary` cross-compiles 2.12 + 2.13 — use `scala-collection-compat` there. -- Java 8 target bytecode (`-source 1.8 -target 1.8`) for backward compatibility. - -### Spring / Swagger - -- REST controllers use Spring MVC annotations. Swagger annotations from `springdoc-openapi-ui` (OpenAPI 3). -- `SecurityConfig` defines which paths are public (`permitAll`) vs authenticated. Update this when adding new public endpoints. -- Exception handling is centralized in `RestResponseEntityExceptionHandler` (`@ControllerAdvice`). diff --git a/CODEBASE_ANALYSIS.md b/CODEBASE_ANALYSIS.md deleted file mode 100644 index 444805d..0000000 --- a/CODEBASE_ANALYSIS.md +++ /dev/null @@ -1,753 +0,0 @@ -# Login Service - Comprehensive Codebase Analysis - -## 1. OVERALL PROJECT STRUCTURE & TECHNOLOGY STACK - -### Language & Framework -- **Language**: Scala 2.12/2.13 -- **Build Tool**: SBT (Scala Build Tool) -- **Web Framework**: Spring Boot 2.7.8 -- **Security**: Spring Security 5.7.6 -- **Project Structure**: Multi-module SBT project with 3 modules: - 1. **api** - Main REST service with authentication/JWT logic - 2. **clientLibrary** - Client library for token validation - 3. **examples** - Example usage - -### Key Technologies -- **JWT Signing**: JJWT 0.11.5 (Java JWT library) with RS256 algorithm -- **Key Management**: Nimbus JOSE+JWT 9.31 -- **LDAP**: Spring Security LDAP -- **Kerberos**: Spring Security Kerberos (1.0.1.RELEASE) -- **AWS Integration**: AWS SDK (Secrets Manager, SSM Parameter Store) -- **Config**: PureConfig 0.17.2 (YAML-based) -- **API Documentation**: SpringDoc OpenAPI UI 1.6.14 (Swagger) - -### Build Files -- **Primary**: `/Users/ab006hm/Projects/login-service/build.sbt` (80 lines) -- **Dependencies**: `/Users/ab006hm/Projects/login-service/project/Dependencies.scala` -- **Plugins**: `/Users/ab006hm/Projects/login-service/project/plugins.sbt` - ---- - -## 2. AUTHENTICATION MECHANISMS & HOW THEY WORK - -### Multiple Auth Providers (Pluggable Architecture) -The service uses an **ordered provider pattern** allowing multiple auth methods with priority ordering: - -#### A. **Config-Based Users Authentication** -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/ConfigUsersAuthenticationProvider.scala` - -**Class**: `ConfigUsersAuthenticationProvider extends AuthenticationProvider` - -- Simple hardcoded username/password pairs defined in YAML config -- Stores users in a map: `knownUsersMap: Map[String, UserConfig]` -- Authenticates via `UsernamePasswordAuthenticationToken` (Spring Security) -- Returns a `User` principal with username, groups, and optional attributes -- **Use case**: Development/testing or small number of static users - -**Flow**: -``` -1. Client sends Basic Auth credentials -2. ConfigUsersAuthenticationProvider.authenticate() called -3. Looks up username in knownUsersMap -4. Compares password -5. Returns UsernamePasswordAuthenticationToken with User principal -``` - -#### B. **Active Directory LDAP Authentication** -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProvider.scala` (160 lines) - -**Class**: `ActiveDirectoryLDAPAuthenticationProvider extends AuthenticationProvider` - -- Wraps Spring Security's `ActiveDirectoryLdapAuthenticationProvider` -- Supports AD LDAP queries via LDAPS (typically port 636) -- **Service Account**: Username/password for LDAP queries (configurable via: - - Inline config (`in-config-account`) - - AWS Secrets Manager (`aws-secrets-manager-account`) - - AWS Systems Manager Parameter Store (`aws-systems-manager-account`) -- **Retry Logic**: Optional retry mechanism for LDAP failures (configurable attempts + delay) -- **Custom Attributes**: Maps LDAP fields to JWT claims (e.g., `mail` → `email`, `displayname` → `displayname`) -- **User Details Mapper**: `LDAPUserDetailsContextMapperWithOptions` extracts groups (authority) and custom attributes - -**Configuration** (from example.application.yaml): -```yaml -loginsvc: - rest: - auth: - provider: - ldap: - order: 2 - domain: "some.domain.com" - url: "ldaps://some.domain.com:636/" - searchFilter: "(samaccountname={1})" - serviceAccount: - accountPattern: "CN=%s,OU=Users,OU=Organization1,DC=my,DC=domain,DC=com" - inConfigAccount: - username: "svc-ldap" - password: "password" - attributes: - mail: "email" - displayname: "displayname" -``` - -#### C. **Kerberos SPNEGO Authentication** -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/provider/kerberos/KerberosSPNEGOAuthenticationProvider.scala` - -**Class**: `KerberosSPNEGOAuthenticationProvider` - -- Negotiates Kerberos tokens (SPNEGO) in HTTP requests -- Uses **keytab file** for server-side authentication -- Wraps Spring Security Kerberos components: - - `KerberosServiceAuthenticationProvider` - - `SunJaasKerberosTicketValidator` - - `SpnegoAuthenticationProcessingFilter` -- Integrates with LDAP to fetch user details post-Kerberos validation -- **Configuration** (in ActiveDirectoryLDAPConfig): - ```yaml - enableKerberos: - krbFileLocation: "/etc/krb5.conf" - keytabFileLocation: "/etc/keytab" - spn: "HTTP/Host" - debug: true - ``` - -### Authentication Manager Setup -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala` - -**Class**: `AuthManagerConfig` - -- Creates a `ProviderManager` with **ordered list** of `AuthenticationProvider` instances -- Order determined by `order` field in config (1st, 2nd, etc.) -- Each auth method tried in sequence until one succeeds -- Validates that **at least one** auth method is enabled - -### Security Configuration -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala` - -**Class**: `SecurityConfig extends Configuration` - -**Key Components**: -- **Filter Chain**: - - Disables CSRF, enables CORS - - Stateless session management (no server-side sessions) - - Custom `BasicAuthenticationFilter` with special exception handling - - Kerberos filter added before BasicAuth if enabled -- **Public Endpoints** (no auth required): - - `/v3/api-docs*`, `/swagger-ui/**`, `/actuator/**` - - `/token/refresh` (accepts tokens in body) - - `/token/public-key`, `/token/public-keys`, `/token/public-key-jwks` -- **Protected Endpoints**: `/token/generate` (requires authentication) -- **Custom Entry Point**: Handles LDAP connection failures (504 status), other failures (401) - ---- - -## 3. TOKEN GENERATION & JWT LOGIC - -### Token Generation Flow - -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/service/jwt/JWTService.scala` (323 lines) - -**Class**: `JWTService extends Service` - -#### Key Methods: - -**`generateAccessToken(user: User, isRefresh: Boolean = false): AccessToken`** -- Expires in: configurable (default 15 minutes from example config) -- Claims included: - - `sub` (subject): username - - `exp` (expiration): calculated from current time + accessExpTime - - `iat` (issued at): current time - - `kid` (key ID): public key thumbprint - - `groups`: list of user groups (Java List format) - - `type`: "access" token type - - **Custom attributes**: Any optional attributes from user (e.g., mail, displayname) -- Signed with: Private RSA key from `primaryKeyPair` - -**`generateRefreshToken(user: User): RefreshToken`** -- Expires in: configurable (default 9 hours from example config) -- Claims: - - `sub`, `exp`, `iat`, `type` ("refresh") - - **No groups or attributes** (minimal) -- Purpose: Can be exchanged for new access token via `/token/refresh` - -**`refreshTokens(accessToken: AccessToken, refreshToken: RefreshToken): (AccessToken, RefreshToken)`** -- Validates **both** tokens against current and previous public keys -- Parses old access token to extract user info -- Searches for user in repositories to verify still exists & get updated info -- Keeps intersection of old groups (only groups from original token) -- Returns newly generated access token + original refresh token - -### Key Rotation & Management - -**Inline Key Generation** (InMemoryKeyConfig): -- Generates RSA keypair on startup (or on schedule if rotation enabled) -- Stores current and optional previous keypair in memory -- **Key Rotation**: Scheduled via `ScheduledThreadPoolExecutor`: - - Rotates every `keyRotationTime` (e.g., 9 hours) - - Maintains previous key for `keyLayOverTime` (e.g., 15 mins overlap) - - Phases out old key after `keyPhaseOutTime` (e.g., 15 mins) - -**AWS Secrets Manager Keys** (AwsSecretsManagerKeyConfig): -- Fetches public/private key pair from AWS Secrets Manager -- Looks for "AWSCURRENT" (primary) and "AWSPREVIOUS" (secondary) versions -- Polls for updates every `pollTime` -- Respects key lay-over and phase-out periods -- See: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/jwt/AwsSecretsManagerKeyConfig.scala` (160 lines) - -### Token Response Structure - -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/model/TokensWrapper.scala` - -```scala -case class TokensWrapper( - @JsonProperty("token") token: String, // Access token (JWT) - @JsonProperty("refresh") refresh: String // Refresh token (JWT) -) - -case class AccessToken(token: String) extends Token -case class RefreshToken(token: String) extends Token - -object Token { - object TokenType extends Enumeration { - val Access = Value("access") - val Refresh = Value("refresh") - } -} -``` - -**JSON Response Example**: -```json -{ - "token": "eyJhbGc...(access token JWT)...zI1NiJ9", - "refresh": "eyJhbGc...(refresh token JWT)...zI1NiJ9" -} -``` - ---- - -## 4. ROUTING/API LAYER - ALL ENDPOINTS - -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/controller/TokenController.scala` (261 lines) - -**Base Path**: `/token` - -### POST /token/generate -- **Authentication**: Basic Auth or Negotiate (Kerberos) -- **Security**: `@SecurityRequirement(name = "basicAuth")` + `@SecurityRequirement(name = "negotiate")` -- **Query Parameters**: - - `group-prefixes` (optional): CSV list of group prefixes to filter JWT groups - - `case-sensitive` (optional, default=false): Case sensitivity for prefix matching -- **Returns**: `TokensWrapper` (access + refresh tokens) -- **Status**: 200 OK | 401 Unauthorized -- **Implementation**: Calls `jwtService.generateAccessToken()` and `jwtService.generateRefreshToken()` - -### GET /token/experimental/get-generate (Experimental) -- **Same as POST /token/generate** but via GET method -- **Requires**: `loginsvc.rest.experimental.enabled=true` -- **Returns**: `TokensWrapper` - -### POST /token/refresh -- **Authentication**: None required (tokens in body) -- **Request Body**: `TokensWrapper` containing both access and refresh tokens -- **Returns**: `TokensWrapper` (new access token + same refresh token) -- **Status**: 200 OK | 401 Unauthorized (expired/invalid) | 400 Bad Request (malformed) -- **Implementation**: Calls `jwtService.refreshTokens()` - -### GET /token/public-key -- **Authentication**: None (public endpoint) -- **Returns**: `PublicKey` object with Base64-encoded current public key -- **Response**: -```json -{ - "key": "MIIBIjANBgkqhkiG9w0BA..." -} -``` - -### GET /token/public-keys -- **Authentication**: None (public endpoint) -- **Returns**: `PublicKeySet` with list of current + previous public keys -- **Response**: -```json -{ - "keys": [ - { "key": "MIIBIjANBgkqhkiG9w0BA..." }, - { "key": "MIIBIjANBgkqhkiG9w0BA..." } - ] -} -``` - -### GET /token/public-key-jwks -- **Authentication**: None (public endpoint) -- **Returns**: JWKS (JSON Web Key Set) format per RFC 7517 -- **Response** (standard JWKS format): -```json -{ - "keys": [ - { - "kty": "RSA", - "n": "0vx7agoebGcQSuuPiLJXZptN9...", - "e": "AQAB", - "alg": "RS256", - "kid": "abc123" - } - ] -} -``` - -### Other Endpoints (Actuator) -- **GET /actuator/health** - Health check endpoint -- **GET /actuator/info** - Application info -- **GET /v3/api-docs** - OpenAPI 3 JSON -- **GET /v3/api-docs.yaml** - OpenAPI 3 YAML -- **GET /swagger-ui.html** - Swagger UI - ---- - -## 5. THIRD-PARTY AUTH INTEGRATIONS - -### **Current Integrations**: -1. **Active Directory LDAP** - Full support -2. **Kerberos/SPNEGO** - Full support (integrates with LDAP) -3. **AWS Services**: - - AWS Secrets Manager (for storing JWT keys and service account credentials) - - AWS Systems Manager Parameter Store (for service account credentials) - - AWS STS/SSO (dependencies included but not actively used) - -### **NO Current OAuth2 / SAML / Azure AD Integration** -- The search `grep -r "oauth\|saml\|azuread\|entra"` returned **no results** in the Scala code -- Only dependency on OAuth2 is `spring-security-oauth2-jose` (for JWT decoding utilities) - -### **Extensibility Notes**: -The architecture supports adding a new provider. Template would be: -1. Create new `class MyAuthProvider extends AuthenticationProvider` -2. Create config case class `MyAuthConfig extends ConfigValidatable with ConfigOrdering` -3. Add to `AuthManagerConfig.createAuthProviders()` pattern matching -4. Update `ConfigProvider` to load config -5. Implement `authenticate(authentication: Authentication): Authentication` - ---- - -## 6. CONFIGURATION PATTERNS - EXTERNAL CREDENTIALS/SECRETS - -### Main Configuration Loading -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala` (107 lines) - -**Class**: `ConfigProvider extends JwtConfigProvider with AuthConfigProvider with ExperimentalRestConfigProvider` - -- Loads YAML configuration via **PureConfig** -- Spring looks for YAML in standard locations (environment variable or command-line arg) -- Spring property: `spring.config.location` - -### Configuration File Structure -**Example**: `/Users/ab006hm/Projects/login-service/api/src/main/resources/example.application.yaml` - -```yaml -loginsvc: - rest: - # JWT Configuration - jwt: - generate-in-memory: # OR aws-secrets-manager - access-exp-time: 15min - refresh-exp-time: 9h - key-rotation-time: 9h - key-lay-over-time: 15min - key-phase-out-time: 15min - alg-name: "RS256" - - # Authentication Configuration - auth: - provider: - users: - order: 1 - known-users: - - username: "user1" - password: "password1" # PLAINTEXT (security risk!) - groups: [] - attributes: - displayname: "User One" - - ldap: - order: 2 - domain: "some.domain.com" - url: "ldaps://some.domain.com:636/" - searchFilter: "(samaccountname={1})" - serviceAccount: - accountPattern: "CN=%s,OU=Users,DC=domain,DC=com" - # Option 1: Inline credentials - inConfigAccount: - username: "svc-ldap" - password: "password" - # Option 2: AWS Secrets Manager - # awsSecretsManagerAccount: - # secretName: "my-ldap-secret" - # region: "us-east-1" - # usernameFieldName: "username" - # passwordFieldName: "password" - # Option 3: AWS Systems Manager - # awsSystemsManagerAccount: - # parameter: "/ldap/svc-account" - # decryptIfSecure: true - # usernameFieldName: "username" - # passwordFieldName: "password" -``` - -### AWS Credential Management - -**AWS Secrets Manager** (for JWT keys): -- **Fetched via**: `AwsSecretsUtils.fetchSecret()` -- **Location**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/utils/AwsSecretsUtils.scala` -- **Uses**: `DefaultCredentialsProvider` (AWS SDK standard credential chain) - - Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - - IAM role (if running on EC2/ECS) - - AWS credentials file (~/.aws/credentials) -- **Secret Format**: JSON with fields configured via `privateKeyFieldName`, `publicKeyFieldName` -- **Version Staging**: Supports "AWSCURRENT" and "AWSPREVIOUS" version stages - -**AWS Systems Manager Parameter Store** (for service account credentials): -- **Utility**: `AwsSsmUtils` (not shown in current files, but referenced in config) -- **Enables**: Secure parameter retrieval with optional decryption - -### Validation Framework -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/validation/` - -All config classes implement `ConfigValidatable` trait: -- `validate(): ConfigValidationResult` (returns Success or Error) -- `throwErrors()` - throws ConfigValidationException on failure -- Validation called at startup via `ConfigProvider` - ---- - -## 7. DATA MODELS FOR USERS & SESSIONS - -### User Model -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/model/User.scala` - -```scala -case class User( - name: String, // Username - groups: Seq[String], // Assigned groups/roles - optionalAttributes: Map[String, Option[AnyRef]] // Key-value pairs (email, displayname, etc.) -) { - def filterGroupsByPrefixes(prefixes: Set[String], caseSensitive: Boolean): User = { - // Filters groups by prefix (for JWT claims filtering) - } -} -``` - -### User Configuration -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/UsersConfig.scala` - -```scala -case class UserConfig( - username: String, - password: String, // PLAINTEXT in config (security consideration) - groups: Array[String], - attributes: Option[Map[String, String]] -) - -case class UsersConfig( - knownUsers: Array[UserConfig], - order: Int -) -``` - -### LDAP Configuration -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/ActiveDirectoryLDAPConfig.scala` - -```scala -case class ActiveDirectoryLDAPConfig( - domain: String, - url: String, - searchFilter: String, - order: Int, - serviceAccount: ServiceAccountConfig, - enableKerberos: Option[KerberosConfig], - ldapRetry: Option[LdapRetryConfig], - attributes: Option[Map[String, String]] // LDAP field -> JWT claim mapping -) - -case class ServiceAccountConfig( - accountPattern: String, // CN=%s,OU=...,DC=... - inConfigAccount: Option[InConfigAccountConfig], - awsSecretsManagerAccount: Option[AwsSecretsLdapUserConfig], - awsSystemsManagerAccount: Option[AwsSystemsManagerLdapUserConfig] -) -``` - -### Spring Security Integration -- **Principal**: `User` (stored in `Authentication.getPrincipal()`) -- **Authorities**: Groups mapped to `SimpleGrantedAuthority` -- **Session**: STATELESS (no server-side session storage) -- **Token in JWT**: All user info persisted in JWT claims - -### User Repositories (for User Lookup) -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/service/search/` - -**Interface**: `UserRepository.searchForUser(username: String): Option[User]` - -**Implementations**: -1. `UsersFromConfigRepository` - Queries hardcoded users -2. `LdapUserRepository` - Queries LDAP directory -3. `DefaultUserRepositories` - Combines both, tries in order - -**Used for**: Refreshing user info during token refresh (verify user still exists, get updated groups) - ---- - -## 8. DEPENDENCIES DECLARATION - -### Build File -**Location**: `/Users/ab006hm/Projects/login-service/build.sbt` - -**Structure**: -```scala -lazy val parent = (project in file(".")) - .aggregate(api, clientLibrary, examples) - -lazy val api = project - .settings(libraryDependencies ++= apiDependencies) - .enablePlugins(TomcatPlugin, AutomateHeaderPlugin, FilteredJacocoAgentPlugin) - -lazy val clientLibrary = project - .settings(libraryDependencies ++= clientLibDependencies) - .enablePlugins(AutomateHeaderPlugin, FilteredJacocoAgentPlugin) - -lazy val examples = project - .dependsOn(clientLibrary) - .settings(libraryDependencies ++= exampleDependencies) -``` - -### Dependency Definitions -**File**: `/Users/ab006hm/Projects/login-service/project/Dependencies.scala` (149 lines) - -**Key Dependencies**: - -| Component | Library | Version | -|-----------|---------|---------| -| **Web** | spring-boot-starter-web | 2.7.8 | -| **Security** | spring-boot-starter-security | 2.7.8 | -| **LDAP** | spring-security-ldap | 5.7.6 | -| **Kerberos** | spring-security-kerberos-web/client | 1.0.1.RELEASE | -| **JWT Signing** | jjwt-api/impl/jackson | 0.11.5 | -| **Key Management** | nimbus-jose-jwt | 9.31 | -| **JWT Decoding** | spring-security-oauth2-jose | 5.7.6 | -| **AWS** | awssdk-secretsmanager, awssdk-ssm, awssdk-sts | 2.20.x | -| **Config** | pureconfig, pureconfig-yaml | 0.17.2 | -| **API Docs** | springdoc-openapi-ui | 1.6.14 | -| **Scala** | jackson-module-scala, scala-java8-compat | 2.14.2 / 0.9.0 | - -**Test Dependencies**: -- scalatest 3.2.15 -- spring-boot-starter-test 2.7.8 -- spring-security-test 5.7.6 -- scalamock 5.2.0 - ---- - -## 9. TEST PATTERNS - HOW AUTH FLOWS ARE TESTED - -### JWT Service Tests -**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/service/jwt/JWTServiceTest.scala` - -**Test Class**: `JWTServiceTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers` - -**Key Test Cases**: -```scala -// Test data setup -private val userWithoutEmailAndGroups: User = User( - name = "user2", - groups = Seq.empty, - optionalAttributes = Map.empty -) - -// Access token generation & validation -it should "return an access JWT that is verifiable by `publicKey`" in { - val jwt = jwtService.generateAccessToken(userWithoutGroups) - val parsedJWT = parseJWT(jwt) - assert(parsedJWT.isSuccess) -} - -// Token claims validation -it should "return an access JWT with subject equal to User.name and has type access" in { - val jwt = jwtService.generateAccessToken(userWithoutGroups) - parsedJWT.map(_.getBody.getSubject) shouldBe userWithoutGroups.name - parsedJWT.map(_.getBody.get("type", classOf[String])) shouldBe "access" -} - -// Custom attributes in token -it should "return an access JWT with email claim equal to User.email if it is not None" -``` - -**Helper Method**: -```scala -private def parseJWT(jwt: Token, publicKey: PublicKey = jwtService.publicKeys._1): Try[Jws[Claims]] = Try { - Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(jwt.token) -} -``` - -### Token Controller Tests -**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/controller/TokenControllerTest.scala` - -**Test Class**: `TokenControllerTest extends AnyFlatSpec with ControllerIntegrationTestBase` - -**Setup**: -```scala -@WebMvcTest(controllers = Array(classOf[TokenController])) -@Import(Array(classOf[ConfigProvider], classOf[SecurityConfig], classOf[RestResponseEntityExceptionHandler], classOf[AuthManagerConfig])) - -@MockBean private var jwtService: JWTService = _ -``` - -**Test Cases**: -```scala -// Basic token generation with mocked service -it should "return tokens generated by mocked JWTService for the basic-auth authenticated user" in { - when(jwtService.generateAccessToken(FakeAuthentication.fakeUser)).thenReturn(fakeAccessJwt) - when(jwtService.generateRefreshToken(FakeAuthentication.fakeUser)).thenReturn(fakeRefreshJwt) - - mockMvc.perform( - post("/token/generate") - .`with`(authentication(FakeAuthentication.fakeUserAuthentication)) - .contentType(MediaType.APPLICATION_JSON) - ) - .andExpect(status.isOk) - .andExpect(content.json(expectedJsonBody)) -} - -// Group prefix filtering -it should "return tokens... with group-prefixes (single)" in { - // Tests group filtering functionality -} -``` - -### Authentication Provider Tests -**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/provider/ConfigUsersAuthenticationProviderTest.scala` - -**Pattern**: Spring Security's `AuthenticationProvider` testing - -```scala -// Test valid credentials -// Test invalid credentials -// Test missing user -// Test group assignment -``` - -### LDAP Provider Tests -**File**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProviderTest.scala` - -- Uses test LDAP fixtures/mocks -- Tests domain handling, retry logic, attribute mapping - -### Configuration Tests -**Location**: `/Users/ab006hm/Projects/login-service/api/src/test/scala/za/co/absa/loginsvc/rest/config/` - -- `UsersConfigTest.scala` - Validates user config parsing -- `ActiveDirectoryLDAPConfigTest.scala` - Validates LDAP config -- `KerberosConfigTest.scala` - Validates Kerberos config -- `AwsSecretsLdapUserConfigTest.scala` - Tests AWS secret config -- `InMemoryKeyConfigTest.scala` - Tests in-memory key config -- `AwsSecretsManagerKeyConfigTest.scala` - Tests AWS secret key config - -**Test Resource Config**: `/Users/ab006hm/Projects/login-service/api/src/test/resources/application.yaml` - ---- - -## 10. MIDDLEWARE & FILTER CHAINS - -### Security Filter Chain -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/SecurityConfig.scala` - -**Filter Order** (executed top to bottom): - -1. **Kerberos SPNEGO Filter** (if enabled) - - Class: `SpnegoAuthenticationProcessingFilter` - - Configured in: `KerberosSPNEGOAuthenticationProvider.spnegoAuthenticationProcessingFilter` - - Processes: Negotiate header tokens - - Validates: Kerberos tickets against keytab - -2. **Basic Auth Filter** - - Class: `BasicAuthenticationFilter` - - Processes: Authorization: Basic header - - Parses: Base64-encoded username:password - - Creates: `UsernamePasswordAuthenticationToken` - -3. **Authentication Manager** - - Delegates to ordered `AuthenticationProvider` list - - Each provider tries in sequence - - First successful auth wins - -4. **Authorization Filter** (Spring Security) - - Enforces path-based authorization rules - - Public paths: API docs, swagger, actuator, public keys, token refresh - - Protected paths: Require authentication (`/token/generate`) - -### Exception Handling -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/RestResponseEntityExceptionHandler.scala` - -**Custom Auth Entry Point** (in SecurityConfig): -```scala -private def customAuthenticationEntryPoint: AuthenticationEntryPoint = - (request, response, authException) => { - authException match { - case LdapConnectionException(msg, _) => - response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT) // 504 - response.write(s"""{"error": "LDAP connection failed: $msg"}""") - case _ => - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) // 401 - response.write(s"""{"error": "User login error"}""") - } - } -``` - -**Special Error Handling**: -- LDAP connection failures → 504 Gateway Timeout -- Auth failures → 401 Unauthorized -- Expired JWT → 401 Unauthorized -- Malformed JWT → 400 Bad Request - -### CORS & CSRF Configuration -- **CSRF**: Disabled (API is stateless, uses tokens) -- **CORS**: Enabled (allows cross-origin requests) - -### Session Management -- **Policy**: `SessionCreationPolicy.STATELESS` -- **Effect**: No `JSESSIONID` cookies, no server-side session data -- **Token Storage**: JWT tokens sent via Authorization header or request body - -### MVC Configuration -**File**: `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/WebMvcConfig.scala` - -- Likely configures JSON serialization, CORS details, etc. - ---- - -## SUMMARY TABLE: Key Files by Feature - -| Feature | File Path | Key Classes | LOC | -|---------|-----------|-------------|-----| -| **JWT Generation** | `.../service/jwt/JWTService.scala` | `JWTService` | 323 | -| **Token Endpoints** | `.../controller/TokenController.scala` | `TokenController` | 261 | -| **Config Users Auth** | `.../provider/ConfigUsersAuthenticationProvider.scala` | `ConfigUsersAuthenticationProvider` | 60 | -| **LDAP Auth** | `.../provider/ad/ldap/ActiveDirectoryLDAPAuthenticationProvider.scala` | `ActiveDirectoryLDAPAuthenticationProvider` | 160 | -| **Kerberos Auth** | `.../provider/kerberos/KerberosSPNEGOAuthenticationProvider.scala` | `KerberosSPNEGOAuthenticationProvider` | 73 | -| **Security Config** | `.../SecurityConfig.scala` | `SecurityConfig` | 102 | -| **Auth Manager** | `.../AuthManagerConfig.scala` | `AuthManagerConfig` | 71 | -| **Configuration** | `.../config/provider/ConfigProvider.scala` | `ConfigProvider` | 107 | -| **User Model** | `.../model/User.scala` | `User` | 31 | -| **Token Models** | `.../model/TokensWrapper.scala` | `TokensWrapper`, `AccessToken`, `RefreshToken` | 59 | -| **In-Memory Keys** | `.../config/jwt/InMemoryKeyConfig.scala` | `InMemoryKeyConfig` | 61 | -| **AWS Secret Keys** | `.../config/jwt/AwsSecretsManagerKeyConfig.scala` | `AwsSecretsManagerKeyConfig` | 160 | -| **AWS Utils** | `.../utils/AwsSecretsUtils.scala` | `AwsSecretsUtils` | 80 | - ---- - -## DESIGN PATTERNS OBSERVED - -1. **Provider Pattern**: Multiple auth providers with pluggable architecture -2. **Strategy Pattern**: Swappable key configs (in-memory vs AWS) -3. **Factory Pattern**: `AuthManagerConfig` creates providers based on config -4. **Repository Pattern**: `UserRepository` for user lookup abstraction -5. **Decorator Pattern**: `LdapUserDetailsContextMapperWithOptions` wraps base mapper -6. **Configuration Validation**: Custom trait-based validation with result merging -7. **Lazy Evaluation**: User repositories tried lazily via Iterator -8. **Scheduled Tasks**: `ScheduledThreadPoolExecutor` for key rotation - diff --git a/ENTRA_INTEGRATION_GUIDE.md b/ENTRA_INTEGRATION_GUIDE.md deleted file mode 100644 index 467a9e3..0000000 --- a/ENTRA_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,635 +0,0 @@ -# MS Entra (Azure AD) Token Exchange Integration Design Guide - -Based on the login-service codebase analysis, this guide provides a concrete blueprint for adding MS Entra support. - -## ARCHITECTURE OVERVIEW - -### Design Approach -Add MS Entra as a **fourth authentication provider** following the existing pluggable architecture: - -``` -Authentication Flow: - Client → REST API → AuthenticationManager → [Providers in order] - 1. ConfigUsers (if order=1) - 2. LDAP (if order=2) - 3. Kerberos (if order=3) - 4. MS Entra (if order=4) [NEW] -``` - ---- - -## IMPLEMENTATION PLAN - -### Phase 1: Configuration Classes - -**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala` - -```scala -package za.co.absa.loginsvc.rest.config.auth - -import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigValidationException, ConfigValidationResult} -import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess} - -case class MsEntraConfig( - order: Int, - tenantId: String, // Azure tenant ID / directory ID - clientId: String, // Application (client) ID - clientSecret: String, // Client secret (from config or AWS Secrets) - redirectUri: String, // Must match registered redirect URI in Azure - discoveryUrl: Option[String] = None, // Override for testing (defaults to Microsoft standard) - scope: String = "https://graph.microsoft.com/.default", - attributes: Option[Map[String, String]] = None // MS Graph -> JWT claim mapping -) 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"))), - - Option(clientSecret) - .map(_ => ConfigValidationSuccess) - .getOrElse(ConfigValidationError(ConfigValidationException("clientSecret is empty"))), - - Option(redirectUri) - .map(_ => ConfigValidationSuccess) - .getOrElse(ConfigValidationError(ConfigValidationException("redirectUri is empty"))) - ) - - results.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge) - } else ConfigValidationSuccess - } -} - -// Optional: AWS Secrets Manager variant for clientSecret -case class MsEntraAwsSecretsConfig( - order: Int, - tenantId: String, - clientId: String, - awsSecretsManagerConfig: AwsSecretReference, // secret name, region, field name - redirectUri: String, - discoveryUrl: Option[String] = None, - scope: String = "https://graph.microsoft.com/.default", - attributes: Option[Map[String, String]] = None -) extends ConfigValidatable with ConfigOrdering { ... } - -case class AwsSecretReference( - secretName: String, - region: String, - clientSecretFieldName: String -) -``` - ---- - -### Phase 2: HTTP Client & Token Exchange - -**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraClient.scala` - -```scala -package za.co.absa.loginsvc.rest.provider.entra - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import org.slf4j.LoggerFactory -import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig -import scala.io.Source -import scala.util.{Try, Using} - -class MsEntraClient(config: MsEntraConfig, objectMapper: ObjectMapper) { - - private val logger = LoggerFactory.getLogger(classOf[MsEntraClient]) - - /** - * Exchange authorization code for access token (OAuth2 token endpoint) - * Called after user authenticates via MS Entra login page - */ - def exchangeCodeForToken(authorizationCode: String): MsEntraTokenResponse = { - val tokenUrl = s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" - - val params = Map( - "client_id" -> config.clientId, - "client_secret" -> config.clientSecret, - "code" -> authorizationCode, - "redirect_uri" -> config.redirectUri, - "grant_type" -> "authorization_code", - "scope" -> config.scope - ) - - val response = postRequest(tokenUrl, params) - parseTokenResponse(response) - } - - /** - * Refresh access token using refresh token - */ - def refreshAccessToken(refreshToken: String): MsEntraTokenResponse = { - val tokenUrl = s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" - - val params = Map( - "client_id" -> config.clientId, - "client_secret" -> config.clientSecret, - "refresh_token" -> refreshToken, - "grant_type" -> "refresh_token", - "scope" -> config.scope - ) - - postRequest(tokenUrl, params).map(parseTokenResponse).get - } - - /** - * Call Microsoft Graph API to get user profile information - */ - def getUserInfo(accessToken: String): MsEntraUserInfo = { - val graphUrl = "https://graph.microsoft.com/v1.0/me" - - val response = getRequest(graphUrl, accessToken) - parseUserInfoResponse(response) - } - - private def postRequest(url: String, params: Map[String, String]): Try[String] = Try { - val connection = new java.net.URL(url).openConnection().asInstanceOf[java.net.HttpURLConnection] - connection.setRequestMethod("POST") - connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") - connection.setDoOutput(true) - - val body = params.map { case (k, v) => s"$k=${java.net.URLEncoder.encode(v, "UTF-8")}" } - .mkString("&") - - connection.getOutputStream.write(body.getBytes("UTF-8")) - - Using(Source.fromInputStream(connection.getInputStream))(_.mkString).get - } - - private def getRequest(url: String, accessToken: String): Try[String] = Try { - val connection = new java.net.URL(url).openConnection().asInstanceOf[java.net.HttpURLConnection] - connection.setRequestMethod("GET") - connection.setRequestProperty("Authorization", s"Bearer $accessToken") - - Using(Source.fromInputStream(connection.getInputStream))(_.mkString).get - } - - private def parseTokenResponse(jsonStr: String): MsEntraTokenResponse = { - val json: JsonNode = objectMapper.readTree(jsonStr) - MsEntraTokenResponse( - accessToken = json.get("access_token").asText(), - refreshToken = Option(json.get("refresh_token")).map(_.asText()), - expiresIn = json.get("expires_in").asInt(), - idToken = Option(json.get("id_token")).map(_.asText()) - ) - } - - private def parseUserInfoResponse(jsonStr: String): MsEntraUserInfo = { - val json: JsonNode = objectMapper.readTree(jsonStr) - MsEntraUserInfo( - userId = json.get("id").asText(), - userPrincipalName = json.get("userPrincipalName").asText(), - displayName = Option(json.get("displayName")).map(_.asText()), - mail = Option(json.get("mail")).map(_.asText()), - jobTitle = Option(json.get("jobTitle")).map(_.asText()) - ) - } -} - -case class MsEntraTokenResponse( - accessToken: String, - refreshToken: Option[String], - expiresIn: Int, - idToken: Option[String] -) - -case class MsEntraUserInfo( - userId: String, - userPrincipalName: String, - displayName: Option[String], - mail: Option[String], - jobTitle: Option[String] -) -``` - ---- - -### Phase 3: Authentication Provider - -**New File**: `api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraAuthenticationProvider.scala` - -```scala -package za.co.absa.loginsvc.rest.provider.entra - -import com.fasterxml.jackson.databind.ObjectMapper -import io.jsonwebtoken.Jwts -import org.slf4j.LoggerFactory -import org.springframework.security.authentication.{AuthenticationProvider, BadCredentialsException} -import org.springframework.security.core.Authentication -import org.springframework.security.core.authority.SimpleGrantedAuthority -import za.co.absa.loginsvc.model.User -import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig - -import scala.collection.JavaConverters._ -import scala.util.Try - -/** - * Authenticates users via MS Entra (Azure AD) token exchange. - * - * Expected flow: - * 1. Client obtains authorization code from MS Entra login page - * 2. Client sends code to /token/generate via special header or param - * 3. This provider exchanges code for access token - * 4. Provider fetches user info from Microsoft Graph API - * 5. Provider creates User object with groups from Graph - * 6. JWTService then generates login-service JWT tokens - */ -class MsEntraAuthenticationProvider( - config: MsEntraConfig, - objectMapper: ObjectMapper -) extends AuthenticationProvider { - - private val logger = LoggerFactory.getLogger(classOf[MsEntraAuthenticationProvider]) - private val entraClient = new MsEntraClient(config, objectMapper) - - override def authenticate(authentication: Authentication): Authentication = { - val entraAuth = authentication.asInstanceOf[MsEntraAuthenticationToken] - val authorizationCode = entraAuth.getCredentials.toString - - logger.info(s"Authenticating via MS Entra with authorization code") - - try { - // Step 1: Exchange authorization code for access token - val tokenResponse = entraClient.exchangeCodeForToken(authorizationCode) - logger.debug(s"Received access token from MS Entra") - - // Step 2: Parse ID token (optional) to get basic user info - val idTokenClaims = tokenResponse.idToken.flatMap { token => - Try { - val claims = Jwts.parserBuilder() - .setSigningKey("") // ID tokens are typically validated against published keys - .build() - .parseClaimsJws(token) - .getBody - Option(claims) - }.toOption.flatten - } - - // Step 3: Fetch detailed user info from Microsoft Graph - val graphUserInfo = entraClient.getUserInfo(tokenResponse.accessToken) - logger.info(s"Retrieved user info for ${graphUserInfo.userPrincipalName}") - - // Step 4: Fetch user's group memberships from Microsoft Graph - val userGroups = fetchUserGroups(tokenResponse.accessToken, graphUserInfo.userId) - logger.debug(s"User ${graphUserInfo.userPrincipalName} has groups: $userGroups") - - // Step 5: Build User object - val userAttributes = Map( - "mail" -> graphUserInfo.mail, - "displayname" -> graphUserInfo.displayName, - "jobTitle" -> graphUserInfo.jobTitle - ).collect { case (k, Some(v)) => k -> Some(v) } - - val principal = User( - name = graphUserInfo.userPrincipalName, - groups = userGroups, - optionalAttributes = userAttributes - ) - - // Step 6: Return successful authentication - val token = new MsEntraAuthenticationToken(principal, authorizationCode) - token.setAuthenticated(true) - token.setDetails(Map( - "accessToken" -> tokenResponse.accessToken, - "refreshToken" -> tokenResponse.refreshToken.getOrElse(""), - "expiresIn" -> tokenResponse.expiresIn - ).asJava) - token - - } catch { - case e: Throwable => - logger.error(s"MS Entra authentication failed: ${e.getMessage}", e) - throw new BadCredentialsException("MS Entra authentication failed", e) - } - } - - override def supports(authentication: Class[_]): Boolean = - authentication == classOf[MsEntraAuthenticationToken] - - /** - * Fetch user's group memberships from Microsoft Graph - * Requires User.Read and Group.Read.All permissions - */ - private def fetchUserGroups(accessToken: String, userId: String): Seq[String] = { - try { - // Call: GET https://graph.microsoft.com/v1.0/me/memberOf - // Returns: List of group IDs, names, etc. - - // Placeholder: Actual implementation would use MsEntraClient or direct HTTP call - Seq("entra-user") // Minimum group - } catch { - case e: Throwable => - logger.warn(s"Failed to fetch user groups from MS Entra: ${e.getMessage}", e) - Seq.empty[String] - } - } -} - -/** - * Custom Authentication token for MS Entra - * Stores authorization code and later the access token - */ -class MsEntraAuthenticationToken( - principal: Any, - credentials: Any -) extends org.springframework.security.core.AbstractAuthenticationToken( - java.util.Collections.emptyList() -) { - setAuthenticated(false) - setPrincipal(principal) - setCredentials(credentials) - - override def getCredentials: AnyRef = credentials.asInstanceOf[AnyRef] - override def getPrincipal: AnyRef = principal.asInstanceOf[AnyRef] -} -``` - ---- - -### Phase 4: New REST Endpoints - -**File**: Modify `/Users/ab006hm/Projects/login-service/api/src/main/scala/za/co/absa/loginsvc/rest/controller/TokenController.scala` - -Add new endpoint for MS Entra token exchange: - -```scala -@PostMapping( - path = Array("/generate-entra"), - produces = Array(MediaType.APPLICATION_JSON_VALUE) -) -@ResponseStatus(HttpStatus.OK) -@Operation( - summary = "Generate tokens via MS Entra (Azure AD) token exchange", - description = "Exchanges MS Entra authorization code for login-service JWT tokens" -) -def generateTokenEntra( - @RequestHeader("X-Entra-Code") entraAuthCode: String, - @RequestParam("group-prefixes") groupPrefixes: Optional[String], - @RequestParam(name = "case-sensitive", defaultValue = "false") caseSensitive: Boolean -): TokensWrapper = { - - // Create MS Entra authentication token from authorization code - val entraToken = new MsEntraAuthenticationToken(null, entraAuthCode) - - // Let AuthenticationManager handle it (will use MsEntraAuthenticationProvider) - val authentication = authManager.authenticate(entraToken) - - val user: User = authentication.getPrincipal match { - case u: User => u - case _ => throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Failed to extract user from MS Entra") - } - - // Apply group filtering if requested - val filteredGroupsUser = user.applyIfDefined(groupPrefixes.toScalaOption) { (u: User, prefixesStr: String) => - val prefixes = prefixesStr.trim.split(',') - u.filterGroupsByPrefixes(prefixes.toSet, caseSensitive) - } - - // Generate login-service tokens - val accessJwt = jwtService.generateAccessToken(filteredGroupsUser) - val refreshJwt = jwtService.generateRefreshToken(filteredGroupsUser) - TokensWrapper.fromTokens(accessJwt, refreshJwt) -} -``` - ---- - -### Phase 5: Configuration Update - -**Update YAML Config** (example.application.yaml): - -```yaml -loginsvc: - rest: - auth: - provider: - users: - order: 1 - known-users: [...] - - ldap: - order: 2 - # ... existing LDAP config - - ms-entra: # NEW - order: 3 - tenantId: "${ENTRA_TENANT_ID}" # From environment or secrets - clientId: "${ENTRA_CLIENT_ID}" - clientSecret: "${ENTRA_CLIENT_SECRET}" - redirectUri: "https://myservice/callback" - # OR use AWS Secrets: - # awsSecretsManagerConfig: - # secretName: "entra-credentials" - # region: "us-east-1" - # clientSecretFieldName: "client-secret" - - scope: "https://graph.microsoft.com/.default" - attributes: - mail: "email" - displayName: "display_name" - jobTitle: "job_title" -``` - ---- - -### Phase 6: Integration with AuthManagerConfig - -**Update**: `api/src/main/scala/za/co/absa/loginsvc/rest/AuthManagerConfig.scala` - -```scala -private def createAuthProviders(configs: Array[ConfigOrdering]): Array[AuthenticationProvider] = { - Array.empty[AuthenticationProvider] ++ configs.filter(_.order > 0).sortBy(_.order) - .map { - case c: UsersConfig => new ConfigUsersAuthenticationProvider(c) - case c: ActiveDirectoryLDAPConfig => new ActiveDirectoryLDAPAuthenticationProvider(c) - case c: MsEntraConfig => new MsEntraAuthenticationProvider(c, objectMapper) // NEW - case other => throw new IllegalStateException(s"unsupported config $other") - } -} -``` - ---- - -### Phase 7: Update ConfigProvider - -**Update**: `api/src/main/scala/za/co/absa/loginsvc/rest/config/provider/ConfigProvider.scala` - -```scala -def getMsEntraConfig: Option[MsEntraConfig] = { - val msEntraConfigOption = createConfigClass[MsEntraConfig]("loginsvc.rest.auth.provider.ms-entra") - if (msEntraConfigOption.nonEmpty) - msEntraConfigOption.get.throwErrors() - msEntraConfigOption -} - -// Add to AuthManagerConfig initialization -``` - ---- - -## TESTING STRATEGY - -### Unit Tests - -**New File**: `api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraAuthenticationProviderTest.scala` - -```scala -class MsEntraAuthenticationProviderTest extends AnyFlatSpec with Matchers { - - it should "exchange authorization code for access token" in { - // Mock MsEntraClient - val mockClient = mock[MsEntraClient] - when(mockClient.exchangeCodeForToken("test-code")).thenReturn( - MsEntraTokenResponse("access-token-123", Some("refresh-token"), 3600, None) - ) - - // Test token exchange - } - - it should "create User object with MS Entra user info" in { - // Verify user is created with correct fields from Graph API - } - - it should "handle MS Entra errors gracefully" in { - // Test BadCredentialsException on auth failure - } -} -``` - -### Integration Tests - -```scala -@WebMvcTest(controllers = Array(classOf[TokenController])) -class MsEntraTokenControllerTest extends AnyFlatSpec { - - it should "generate tokens via MS Entra code exchange" in { - mockMvc.perform( - post("/token/generate-entra") - .header("X-Entra-Code", "auth-code-123") - .contentType(MediaType.APPLICATION_JSON) - ) - .andExpect(status.isOk) - .andExpect(jsonPath("$.token").exists()) - .andExpect(jsonPath("$.refresh").exists()) - } -} -``` - ---- - -## SECURITY CONSIDERATIONS - -1. **Client Secret Management**: - - Never hardcode in YAML (use environment variables or AWS Secrets) - - Rotate regularly - - Use AWS Secrets Manager with version staging - -2. **Authorization Code**: - - Must be exchanged immediately (short-lived, ~10 minutes) - - Should include PKCE (Proof Key for Code Exchange) for public clients - - Prevent authorization code interception via HTTPS only - -3. **Token Validation**: - - Validate ID token signature against Azure's published keys - - Validate `aud` (audience) claim matches `clientId` - - Check `iss` (issuer) matches tenant - -4. **Refresh Tokens**: - - Store securely (not in localStorage on client side) - - Use refresh token rotation if supported - - Include `refresh_token_expires_in` for lifecycle management - -5. **Scopes**: - - Request minimum necessary scopes (principle of least privilege) - - `https://graph.microsoft.com/.default` for all permissions - - Could restrict to `User.Read` if only basic info needed - -6. **HTTPS Only**: - - All Entra communication must use TLS - - Redirect URI must use HTTPS in production - ---- - -## MIGRATION PATH - -### Backward Compatibility -- All existing auth methods (config users, LDAP, Kerberos) continue to work unchanged -- New MS Entra provider added as **optional 4th provider** -- No breaking changes to existing endpoints - -### Gradual Rollout -1. Deploy with MS Entra disabled (`order: 0` or omitted) -2. Test with small user subset -3. Enable for specific groups/teams -4. Full rollout when ready - ---- - -## DEPENDENCIES TO ADD - -**Update** `project/Dependencies.scala`: - -```scala -// Microsoft Graph & OAuth2 -lazy val microsoftGraphJavaSDK = "com.microsoft.graph" % "microsoft-graph" % "5.35.0" -lazy val azureIdentity = "com.azure" % "azure-identity" % "1.8.2" -lazy val joseJwt = "com.nimbusds" % "nimbus-jose-jwt" % "9.31" // Already included - -// Or use lightweight HTTP client approach (already have jackson) -``` - ---- - -## KEY DIFFERENCES FROM LDAP INTEGRATION - -| Aspect | LDAP | MS Entra | -|--------|------|----------| -| **Protocol** | LDAP/LDAPS | OAuth2 + REST API | -| **Group Fetch** | LDAP query + service account | MS Graph API + access token | -| **Discovery** | Manual URL/domain config | Azure metadata discovery | -| **Token Exchange** | N/A (direct LDAP bind) | Code → Access Token → User Info | -| **Refresh** | No token refresh | Token refresh via refresh token | -| **Attributes** | LDAP attributes directly | MS Graph API fields | - ---- - -## EXAMPLE CLIENT USAGE - -```javascript -// 1. Redirect user to MS Entra login -const entraLoginUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid%20profile%20email`; - -// 2. User logs in, gets authorization code -// Azure redirects to: https://myapp/callback?code=ABC123&session_state=XYZ - -// 3. Exchange code for login-service tokens -const response = await fetch('/token/generate-entra', { - method: 'POST', - headers: { - 'X-Entra-Code': 'ABC123', - 'Content-Type': 'application/json' - } -}); - -const { token, refresh } = await response.json(); - -// 4. Use token for API requests -fetch('/api/protected', { - headers: { - 'Authorization': `Bearer ${token}` - } -}); -``` - From 387593699a481afc2c07a065150552c57112c41e Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 11 Mar 2026 11:01:44 +0100 Subject: [PATCH 3/9] chore: update license header year to 2026 in new files --- .../za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala | 2 +- .../loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala | 2 +- .../loginsvc/rest/provider/entra/MsEntraTokenValidator.scala | 2 +- .../co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala | 2 +- .../rest/provider/entra/MsEntraBearerTokenFilterTest.scala | 2 +- .../rest/provider/entra/MsEntraTokenValidatorTest.scala | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala index 73badc4..0133cea 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala index 49755f0..71665ab 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala index 012f806..b004478 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala index 67b06ee..e746ad7 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala index 10a3d19..3db4f09 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala index fa2bc2d..c11455f 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2023 ABSA Group Limited + * Copyright 2026 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. From fa1e6726da9b5370972bf4c765a0f5f3c83d2393 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 11 Mar 2026 12:37:15 +0100 Subject: [PATCH 4/9] feat: support multiple/any audiences for Entra token validation - Replace single 'audience: String' with 'audiences: List[String]' in MsEntraConfig - Empty list means accept any audience from the tenant (only issuer + signature verified) - Non-empty list requires token aud to intersect with configured audiences - Audience check is done manually (post-processing) to support 'any of' semantics, since Nimbus DefaultJWTClaimsVerifier uses 'all of' semantics - Add run/fork and run/baseDirectory to build.sbt to fix sbt run with TomcatPlugin - Add run/javaOptions for macOS Keychain trust store (corporate proxy SSL) --- .../main/resources/example.application.yaml | 6 ++++-- .../rest/config/auth/MsEntraConfig.scala | 12 ++++------- .../entra/MsEntraBearerTokenFilter.scala | 2 +- .../entra/MsEntraTokenValidator.scala | 20 +++++++++++++------ .../rest/config/auth/MsEntraConfigTest.scala | 13 ++++++------ .../entra/MsEntraBearerTokenFilterTest.scala | 2 +- .../entra/MsEntraTokenValidatorTest.scala | 10 ++++++++-- build.sbt | 7 ++++++- 8 files changed, 44 insertions(+), 28 deletions(-) diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index acba452..0bce6ce 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -109,8 +109,10 @@ loginsvc: #tenant-id: "your-tenant-id" # Application (client) ID registered in Entra #client-id: "your-client-id" - # Expected value of the JWT 'aud' claim — typically "api://your-client-id" - #audience: "api://your-client-id" + # Accepted JWT 'aud' claim values — tokens from any listed application are accepted + #audiences: + #- "api://your-client-id" + #- "other-app-client-id" # Optional mapping from Entra JWT claim names to LS JWT claim names #attributes: #preferred_username: "upn" diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala index 0133cea..0ec0939 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. @@ -24,14 +24,14 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigVali * * @param tenantId Azure AD tenant ID (directory ID) * @param clientId Application (client) ID registered in Entra - * @param audience Expected value of the JWT 'aud' claim, e.g. "api://your-client-id" + * @param audiences Accepted JWT 'aud' claim values — tokens from any listed app are accepted * @param order Provider ordering (0 = disabled, 1+ = active) * @param attributes Optional mapping from Entra JWT claim names to LS JWT claim names */ case class MsEntraConfig( tenantId: String, clientId: String, - audience: String, + audiences: List[String], order: Int, attributes: Option[Map[String, String]] ) extends ConfigValidatable with ConfigOrdering { @@ -48,11 +48,7 @@ case class MsEntraConfig( Option(clientId) .map(_ => ConfigValidationSuccess) - .getOrElse(ConfigValidationError(ConfigValidationException("clientId is empty"))), - - Option(audience) - .map(_ => ConfigValidationSuccess) - .getOrElse(ConfigValidationError(ConfigValidationException("audience is empty"))) + .getOrElse(ConfigValidationError(ConfigValidationException("clientId is empty"))) ) results.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala index 71665ab..49755f0 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilter.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala index b004478..8ff57ca 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. @@ -87,17 +87,25 @@ class MsEntraTokenValidator( ) jwtProcessor.setJWSKeySelector(keySelector) - // Verify standard claims: iss, aud, exp, nbf + // Verify signature, issuer, expiry and required claims val requiredClaims = new DefaultJWTClaimsVerifier[NimbusSecurityContext]( - new JWTClaimsSet.Builder() - .issuer(expectedIssuer) - .audience(config.audience) - .build(), + new JWTClaimsSet.Builder().issuer(expectedIssuer).build(), Set("sub", "iat", "exp").asJava ) jwtProcessor.setJWTClaimsSetVerifier(requiredClaims) val claims: JWTClaimsSet = jwtProcessor.process(rawToken, null) + + // Audience check: if audiences are configured, token must contain at least one + if (config.audiences.nonEmpty) { + val tokenAudiences = Option(claims.getAudience).map(_.asScala.toSet).getOrElse(Set.empty) + val configAudiences = config.audiences.toSet + if (tokenAudiences.intersect(configAudiences).isEmpty) + throw new BadJWTException( + s"JWT aud claim has value $tokenAudiences, must include one of $configAudiences" + ) + } + extractUser(claims) } recoverWith { case e: BadJWTException => diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala index e746ad7..86f1a84 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfigTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. @@ -26,7 +26,7 @@ class MsEntraConfigTest extends AnyFlatSpec with Matchers { private val validConfig = MsEntraConfig( tenantId = "test-tenant-id", clientId = "test-client-id", - audience = "api://test-client-id", + audiences = List("api://test-client-id", "other-app-client-id"), order = 1, attributes = Some(Map("preferred_username" -> "upn", "email" -> "email")) ) @@ -53,9 +53,8 @@ class MsEntraConfigTest extends AnyFlatSpec with Matchers { result shouldBe ConfigValidationError(ConfigValidationException("clientId is empty")) } - it should "fail on null audience" in { - val result = validConfig.copy(audience = null).validate() - result shouldBe ConfigValidationError(ConfigValidationException("audience is empty")) + it should "pass validation with empty audiences (accept any token from the tenant)" in { + validConfig.copy(audiences = List.empty).validate() shouldBe ConfigValidationSuccess } it should "accumulate multiple validation errors" in { @@ -65,8 +64,8 @@ class MsEntraConfigTest extends AnyFlatSpec with Matchers { result.errors.map(_.msg) should contain allOf ("tenantId is empty", "clientId is empty") } - it should "pass validation when disabled (order=0) even with null fields" in { - MsEntraConfig(tenantId = null, clientId = null, audience = null, order = 0, attributes = None) + it should "pass validation when disabled (order=0) even with empty fields" in { + MsEntraConfig(tenantId = null, clientId = null, audiences = List.empty, order = 0, attributes = None) .validate() shouldBe ConfigValidationSuccess } diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala index 3db4f09..10a3d19 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraBearerTokenFilterTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala index c11455f..5a049d0 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala @@ -1,5 +1,5 @@ /* - * Copyright 2026 ABSA Group Limited + * 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. @@ -36,12 +36,13 @@ class MsEntraTokenValidatorTest extends AnyFlatSpec with Matchers { private val tenantId = "test-tenant-id" private val clientId = "test-client-id" private val audience = "api://test-client-id" + private val audience2 = "other-app-client-id" private val issuer = s"https://login.microsoftonline.com/$tenantId/v2.0" private val config = MsEntraConfig( tenantId = tenantId, clientId = clientId, - audience = audience, + audiences = List(audience, audience2), order = 1, attributes = Some(Map("email" -> "email")) ) @@ -158,6 +159,11 @@ class MsEntraTokenValidatorTest extends AnyFlatSpec with Matchers { result.isFailure shouldBe true } + it should "accept a token with the second configured audience" in { + val token = buildToken(audienceOverride = audience2) + validator.validate(token) shouldBe a[Success[_]] + } + it should "return a Failure for a token with wrong audience" in { val token = buildToken(audienceOverride = "api://different-client") val result = validator.validate(token) diff --git a/build.sbt b/build.sbt index e7ab859..36cc3d6 100644 --- a/build.sbt +++ b/build.sbt @@ -30,6 +30,8 @@ lazy val commonJacocoExcludes: Seq[String] = Seq( lazy val commonJavacOptions = Seq("-source", "1.8", "-target", "1.8", "-Xlint") // deliberately making backwards compatible with J8 +addCommandAlias("runLocal", "api/run --spring.config.location=api/src/main/resources/local.application.yaml") + lazy val parent = (project in file(".")) .aggregate(api, clientLibrary, examples) .enablePlugins(FilteredJacocoAgentPlugin) @@ -50,7 +52,10 @@ lazy val api = project // no need to define file, because path is same as val na webappWebInfClasses := true, inheritJarManifest := true, javacOptions ++= commonJavacOptions, - publish / skip := true + publish / skip := true, + run / fork := true, // required: avoids URLStreamHandlerFactory conflict with xsbt-web-plugin + run / baseDirectory := (ThisBuild / baseDirectory).value, // forked process runs from repo root + run / javaOptions += "-Djavax.net.ssl.trustStoreType=KeychainStore" // use macOS Keychain CA certs (corporate proxy) ).enablePlugins(TomcatPlugin) .enablePlugins(AutomateHeaderPlugin) .enablePlugins(FilteredJacocoAgentPlugin) From 84628174e72167f14d14ef2a0676a393db4b8eec Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 11 Mar 2026 13:18:51 +0100 Subject: [PATCH 5/9] feat: resolve username via MS Graph onPremisesSamAccountName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MsEntraGraphClient: calls Graph API with client credentials to look up onPremisesSamAccountName and onPremisesDomainName for the authenticated user, returning NETBIOS\samAccountName format - Add GraphUsernameResolver trait for testability - Add clientSecret and domains fields to MsEntraConfig (both optional; when absent, username resolution falls back to UPN from the token) - MsEntraTokenValidator: use graph resolver when clientSecret is configured; fall back to preferred_username/upn/sub when None - Add two tests: graph resolves username, graph returns None → UPN fallback - Update example.application.yaml with new config fields documentation - Add User.Read.All application permission to Login Service - DEV app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/example.application.yaml | 13 +- .../rest/config/auth/MsEntraConfig.scala | 19 +- .../provider/entra/MsEntraGraphClient.scala | 171 ++++++++++++++++++ .../entra/MsEntraTokenValidator.scala | 17 +- .../entra/MsEntraTokenValidatorTest.scala | 22 +++ 5 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index 0bce6ce..b79e224 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -109,10 +109,21 @@ loginsvc: #tenant-id: "your-tenant-id" # Application (client) ID registered in Entra #client-id: "your-client-id" - # Accepted JWT 'aud' claim values — tokens from any listed application are accepted + # Client secret used to call MS Graph API for on-premises username resolution. + # When set, the authenticated user's UPN is exchanged for their DOMAIN\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. + # Example: users in corp.example.com will be resolved as CORP\samAccountName + #domains: + #corp.example.com: "CORP" + #another.domain.com: "ANOTHER" # Optional mapping from Entra JWT claim names to LS JWT claim names #attributes: #preferred_username: "upn" diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala index 0ec0939..cb139fa 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -22,16 +22,25 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigVali /** * 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 audiences Accepted JWT 'aud' claim values — tokens from any listed app are accepted - * @param order Provider ordering (0 = disabled, 1+ = active) - * @param attributes Optional mapping from Entra JWT claim names to LS JWT claim names + * @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 `NETBIOS\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. + * @param order Provider ordering (0 = disabled, 1+ = active) + * @param attributes Optional mapping from Entra JWT claim names to LS JWT claim names */ 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]] ) extends ConfigValidatable with ConfigOrdering { diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala new file mode 100644 index 0000000..8351d9f --- /dev/null +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala @@ -0,0 +1,171 @@ +/* + * 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 com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} +import org.slf4j.LoggerFactory +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig + +import java.io.{DataOutputStream, InputStream} +import java.net.{HttpURLConnection, URL, URLEncoder} +import java.util.concurrent.TimeUnit +import scala.io.Source +import scala.util.{Failure, Success, Try} + +/** + * Resolves a username via the MS Graph API by looking up the user's on-premises SAM account name. + * + * Returns `Some("NETBIOS\\samAccountName")` when on-premises AD attributes are present, + * or `None` to signal the caller to fall back to the UPN from the token. + */ +trait GraphUsernameResolver { + def resolveUsername(upn: String): Option[String] +} + +/** + * MS Graph-based implementation of [[GraphUsernameResolver]]. + * + * Acquires an access token for the Graph API via client credentials (clientId + clientSecret), + * then queries `GET /v1.0/users/{upn}?$select=onPremisesSamAccountName,onPremisesDomainName`. + * The DNS domain name is mapped to a NetBIOS name via the `domains` config map and the result + * is returned as `NETBIOS\samAccountName`. + * + * Falls back to `None` (i.e., use UPN) when: + * - the user has no on-premises AD attributes (e.g. cloud-only or external-tenant users) + * - the `onPremisesDomainName` is not in the `domains` map + * - any HTTP or parsing error occurs + * + * The Graph access token is cached for 50 minutes. + * + * @param config Entra config — must have `clientSecret` set; `domains` maps DNS domain → NetBIOS name. + */ +class MsEntraGraphClient(config: MsEntraConfig) extends GraphUsernameResolver { + + private val logger = LoggerFactory.getLogger(classOf[MsEntraGraphClient]) + + private val tokenEndpoint = + s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" + + private val graphUsersBaseUrl = "https://graph.microsoft.com/v1.0/users" + + private val domainMap: Map[String, String] = config.domains.getOrElse(Map.empty) + + // Cache the Graph access token; expires well before the typical 1-hour token lifetime + private val accessTokenCache: LoadingCache[String, String] = + CacheBuilder.newBuilder() + .expireAfterWrite(50, TimeUnit.MINUTES) + .build(new CacheLoader[String, String] { + override def load(key: String): String = fetchAccessToken() + }) + + override def resolveUsername(upn: String): Option[String] = { + Try { + val accessToken = accessTokenCache.get("token") + val (samOpt, domainOpt) = queryGraphForUser(accessToken, upn) + + (samOpt.filter(_.nonEmpty), domainOpt.filter(_.nonEmpty)) match { + case (Some(sam), Some(dnsDomain)) => + domainMap.get(dnsDomain) match { + case Some(netbios) => + logger.debug(s"Resolved user '$upn' to '$netbios\\$sam' via Graph API") + Some(s"$netbios\\$sam") + case None => + logger.error( + s"Unknown onPremisesDomainName '$dnsDomain' for user '$upn'. " + + "Add it to the 'domains' mapping in the Entra config. Falling back to UPN." + ) + None + } + + case _ => + logger.debug(s"User '$upn' has no on-premises AD attributes; using UPN as username") + None + } + } match { + case Success(result) => result + case Failure(e) => + logger.warn(s"Graph API lookup failed for '$upn': ${e.getMessage}") + None + } + } + + private def fetchAccessToken(): String = { + val secret = config.clientSecret.getOrElse( + throw new IllegalStateException("clientSecret is required to call the Graph API") + ) + val body = Seq( + "grant_type" -> "client_credentials", + "client_id" -> config.clientId, + "client_secret" -> secret, + "scope" -> "https://graph.microsoft.com/.default" + ).map { case (k, v) => URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") } + .mkString("&") + + val responseJson = httpPost(tokenEndpoint, body) + parseJsonStringField(responseJson, "access_token") + .getOrElse(throw new IllegalStateException("No access_token in token endpoint response")) + } + + private def queryGraphForUser(accessToken: String, upn: String): (Option[String], Option[String]) = { + val encodedUpn = URLEncoder.encode(upn, "UTF-8") + val url = s"$graphUsersBaseUrl/$encodedUpn?$$select=onPremisesSamAccountName,onPremisesDomainName" + val responseJson = httpGet(url, accessToken) + val sam = parseJsonStringField(responseJson, "onPremisesSamAccountName") + val domain = parseJsonStringField(responseJson, "onPremisesDomainName") + (sam, domain) + } + + private def httpPost(urlStr: String, body: String): String = { + val conn = new URL(urlStr).openConnection().asInstanceOf[HttpURLConnection] + conn.setRequestMethod("POST") + conn.setDoOutput(true) + conn.setConnectTimeout(5000) + conn.setReadTimeout(5000) + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + val bytes = body.getBytes("UTF-8") + conn.setRequestProperty("Content-Length", bytes.length.toString) + val out = new DataOutputStream(conn.getOutputStream) + try { out.write(bytes) } finally { out.close() } + readResponse(conn) + } + + private def httpGet(urlStr: String, bearerToken: String): String = { + val conn = new URL(urlStr).openConnection().asInstanceOf[HttpURLConnection] + conn.setRequestMethod("GET") + conn.setConnectTimeout(5000) + conn.setReadTimeout(5000) + conn.setRequestProperty("Authorization", s"Bearer $bearerToken") + conn.setRequestProperty("ConsistencyLevel", "eventual") + readResponse(conn) + } + + private def readResponse(conn: HttpURLConnection): String = { + val status = conn.getResponseCode + val stream: InputStream = + if (status >= 200 && status < 300) conn.getInputStream else conn.getErrorStream + val body = Source.fromInputStream(stream, "UTF-8").mkString + if (status >= 200 && status < 300) body + else throw new RuntimeException(s"HTTP $status from ${conn.getURL.getHost}: $body") + } + + /** Extracts a string-valued field from a flat JSON object, returning None if absent or null. */ + private def parseJsonStringField(json: String, fieldName: String): Option[String] = { + val escapedName = java.util.regex.Pattern.quote(fieldName) + val pattern = ("\"" + escapedName + "\"\\s*:\\s*\"([^\"]+)\"").r + pattern.findFirstMatchIn(json).map(_.group(1)) + } +} diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala index 8ff57ca..3948543 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -40,16 +40,22 @@ import scala.util.{Failure, Success, Try} * The discovery URL follows the Microsoft standard: * https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration * - * @param config Entra configuration - * @param jwkSourceOverride Optional override for the JWK source (used in tests to avoid HTTP calls) + * @param config Entra configuration + * @param jwkSourceOverride Optional override for the JWK source (used in tests to avoid HTTP calls) + * @param graphClientOverride Optional override for the Graph username resolver (used in tests) */ class MsEntraTokenValidator( config: MsEntraConfig, - private[entra] val jwkSourceOverride: Option[JWKSource[NimbusSecurityContext]] = None + private[entra] val jwkSourceOverride: Option[JWKSource[NimbusSecurityContext]] = None, + private[entra] val graphClientOverride: Option[GraphUsernameResolver] = None ) { private val logger = LoggerFactory.getLogger(classOf[MsEntraTokenValidator]) + // Use the injected graph resolver (for tests) or create one from config if clientSecret is set + private val graphResolver: Option[GraphUsernameResolver] = + graphClientOverride.orElse(config.clientSecret.map(_ => new MsEntraGraphClient(config))) + private val discoveryUrl = s"https://login.microsoftonline.com/${config.tenantId}/v2.0/.well-known/openid-configuration" @@ -118,11 +124,14 @@ class MsEntraTokenValidator( } private def extractUser(claims: JWTClaimsSet): User = { - val username = Option(claims.getStringClaim("preferred_username")) + val rawUsername = Option(claims.getStringClaim("preferred_username")) .orElse(Option(claims.getStringClaim("upn"))) .orElse(Option(claims.getSubject)) .getOrElse(throw new IllegalArgumentException("Entra token has no usable username claim (preferred_username/upn/sub)")) + // Attempt to resolve to on-premises DOMAIN\samAccountName via Graph API; fall back to UPN + val username = graphResolver.flatMap(_.resolveUsername(rawUsername)).getOrElse(rawUsername) + val groups: Seq[String] = Option(claims.getStringListClaim("groups")) .map(_.asScala.toSeq) .getOrElse(Seq.empty) diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala index 5a049d0..556ce04 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala @@ -22,6 +22,8 @@ import com.nimbusds.jose.jwk.{JWKSet, RSAKey} import com.nimbusds.jose.proc.{SecurityContext => NimbusSecurityContext} import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.{mock, when} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig @@ -192,4 +194,24 @@ class MsEntraTokenValidatorTest extends AnyFlatSpec with Matchers { val user = validator.validate(jwt.serialize()).get user.groups shouldBe empty } + + it should "use DOMAIN\\samAccountName from Graph when graphClientOverride resolves the username" in { + val mockGraph = mock(classOf[GraphUsernameResolver]) + when(mockGraph.resolveUsername("user@example.com")).thenReturn(Some("CORP\\jsmith")) + + val validatorWithGraph = new MsEntraTokenValidator(config, Some(jwkSource), Some(mockGraph)) + val token = buildToken() + val user = validatorWithGraph.validate(token).get + user.name shouldBe "CORP\\jsmith" + } + + it should "fall back to UPN when the graph resolver returns None" in { + val mockGraph = mock(classOf[GraphUsernameResolver]) + when(mockGraph.resolveUsername(anyString())).thenReturn(None) + + val validatorWithGraph = new MsEntraTokenValidator(config, Some(jwkSource), Some(mockGraph)) + val token = buildToken(preferredUsername = "fallback@example.com") + val user = validatorWithGraph.validate(token).get + user.name shouldBe "fallback@example.com" + } } From e07eb5c091e7e08f585055d29e0707f658d269b3 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Thu, 12 Mar 2026 13:33:22 +0100 Subject: [PATCH 6/9] fix: normalize Entra Graph usernames Return lowercase samAccountName values from Graph-backed Entra resolution, keep domain mappings as the allow-list for recognized domains, and update logging/tests/docs to reflect the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/example.application.yaml | 5 ++- .../rest/config/auth/MsEntraConfig.scala | 5 ++- .../provider/entra/MsEntraGraphClient.scala | 40 ++++++++++------- .../entra/MsEntraTokenValidator.scala | 2 +- .../entra/MsEntraGraphClientTest.scala | 44 +++++++++++++++++++ .../entra/MsEntraTokenValidatorTest.scala | 6 +-- 6 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index b79e224..24727d2 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -110,7 +110,8 @@ loginsvc: # 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 DOMAIN\samAccountName + # 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; @@ -120,7 +121,7 @@ loginsvc: #- "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. - # Example: users in corp.example.com will be resolved as CORP\samAccountName + # These mapped values are used to allow known domains and log the mapped AB value. #domains: #corp.example.com: "CORP" #another.domain.com: "ANOTHER" diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala index cb139fa..81f6b6c 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -27,11 +27,12 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigVali * @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 `NETBIOS\samAccountName`. + * 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. + * 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 */ diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala index 8351d9f..93a9944 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala @@ -22,6 +22,7 @@ import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig import java.io.{DataOutputStream, InputStream} import java.net.{HttpURLConnection, URL, URLEncoder} +import java.util.Locale import java.util.concurrent.TimeUnit import scala.io.Source import scala.util.{Failure, Success, Try} @@ -29,7 +30,7 @@ import scala.util.{Failure, Success, Try} /** * Resolves a username via the MS Graph API by looking up the user's on-premises SAM account name. * - * Returns `Some("NETBIOS\\samAccountName")` when on-premises AD attributes are present, + * Returns `Some("samaccountname")` when on-premises AD attributes are present, * or `None` to signal the caller to fall back to the UPN from the token. */ trait GraphUsernameResolver { @@ -41,8 +42,8 @@ trait GraphUsernameResolver { * * Acquires an access token for the Graph API via client credentials (clientId + clientSecret), * then queries `GET /v1.0/users/{upn}?$select=onPremisesSamAccountName,onPremisesDomainName`. - * The DNS domain name is mapped to a NetBIOS name via the `domains` config map and the result - * is returned as `NETBIOS\samAccountName`. + * The DNS domain name must exist in the `domains` config map; its mapped AB/NetBIOS value is + * logged, and the result is returned as lower-case `samAccountName`. * * Falls back to `None` (i.e., use UPN) when: * - the user has no on-premises AD attributes (e.g. cloud-only or external-tenant users) @@ -51,7 +52,8 @@ trait GraphUsernameResolver { * * The Graph access token is cached for 50 minutes. * - * @param config Entra config — must have `clientSecret` set; `domains` maps DNS domain → NetBIOS name. + * @param config Entra config — must have `clientSecret` set; `domains` maps DNS domain → + * AB/NetBIOS short name for allow-listing and logging. */ class MsEntraGraphClient(config: MsEntraConfig) extends GraphUsernameResolver { @@ -79,17 +81,7 @@ class MsEntraGraphClient(config: MsEntraConfig) extends GraphUsernameResolver { (samOpt.filter(_.nonEmpty), domainOpt.filter(_.nonEmpty)) match { case (Some(sam), Some(dnsDomain)) => - domainMap.get(dnsDomain) match { - case Some(netbios) => - logger.debug(s"Resolved user '$upn' to '$netbios\\$sam' via Graph API") - Some(s"$netbios\\$sam") - case None => - logger.error( - s"Unknown onPremisesDomainName '$dnsDomain' for user '$upn'. " + - "Add it to the 'domains' mapping in the Entra config. Falling back to UPN." - ) - None - } + resolveMappedUsername(sam, dnsDomain, upn) case _ => logger.debug(s"User '$upn' has no on-premises AD attributes; using UPN as username") @@ -103,6 +95,24 @@ class MsEntraGraphClient(config: MsEntraConfig) extends GraphUsernameResolver { } } + private[entra] def resolveMappedUsername(sam: String, dnsDomain: String, upn: String): Option[String] = { + domainMap.get(dnsDomain) match { + case Some(netbios) => + val normalizedSam = sam.toLowerCase(Locale.ROOT) + logger.debug( + s"Resolved user '$upn' to '$normalizedSam' via Graph API " + + s"(mapped AB value '$netbios' from domain '$dnsDomain')" + ) + Some(normalizedSam) + case None => + logger.error( + s"Unknown onPremisesDomainName '$dnsDomain' for user '$upn'. " + + "Add it to the 'domains' mapping in the Entra config. Falling back to UPN." + ) + None + } + } + private def fetchAccessToken(): String = { val secret = config.clientSecret.getOrElse( throw new IllegalStateException("clientSecret is required to call the Graph API") diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala index 3948543..72b591d 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -129,7 +129,7 @@ class MsEntraTokenValidator( .orElse(Option(claims.getSubject)) .getOrElse(throw new IllegalArgumentException("Entra token has no usable username claim (preferred_username/upn/sub)")) - // Attempt to resolve to on-premises DOMAIN\samAccountName via Graph API; fall back to UPN + // Attempt to resolve to lower-case on-premises samAccountName via Graph API; fall back to UPN val username = graphResolver.flatMap(_.resolveUsername(rawUsername)).getOrElse(rawUsername) val groups: Seq[String] = Option(claims.getStringListClaim("groups")) diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala new file mode 100644 index 0000000..7eef5f3 --- /dev/null +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala @@ -0,0 +1,44 @@ +/* + * 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.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig + +class MsEntraGraphClientTest extends AnyFlatSpec with Matchers { + + private val client = new MsEntraGraphClient( + MsEntraConfig( + tenantId = "tenant-id", + clientId = "client-id", + clientSecret = None, + audiences = Nil, + domains = Some(Map("corp.dsarena.com" -> "CORP")), + order = 1, + attributes = None + ) + ) + + "MsEntraGraphClient" should "return a lowercase samAccountName without the domain prefix" in { + client.resolveMappedUsername("AB006HM", "corp.dsarena.com", "oto.macenauer@absa.africa") shouldBe Some("ab006hm") + } + + it should "return None when the user's domain is not in the configured allow-list" in { + client.resolveMappedUsername("AB006HM", "unknown.domain", "oto.macenauer@absa.africa") shouldBe None + } +} diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala index 556ce04..1056821 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidatorTest.scala @@ -195,14 +195,14 @@ class MsEntraTokenValidatorTest extends AnyFlatSpec with Matchers { user.groups shouldBe empty } - it should "use DOMAIN\\samAccountName from Graph when graphClientOverride resolves the username" in { + it should "use the normalized samAccountName from Graph when graphClientOverride resolves the username" in { val mockGraph = mock(classOf[GraphUsernameResolver]) - when(mockGraph.resolveUsername("user@example.com")).thenReturn(Some("CORP\\jsmith")) + when(mockGraph.resolveUsername("user@example.com")).thenReturn(Some("jsmith")) val validatorWithGraph = new MsEntraTokenValidator(config, Some(jwkSource), Some(mockGraph)) val token = buildToken() val user = validatorWithGraph.validate(token).get - user.name shouldBe "CORP\\jsmith" + user.name shouldBe "jsmith" } it should "fall back to UPN when the graph resolver returns None" in { From 5736a96c6feda8533d159904c8bc98a4da609f53 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Mon, 16 Mar 2026 13:23:49 +0100 Subject: [PATCH 7/9] test: raise Entra Graph coverage Add focused Graph client tests for username normalization and error branches, and make endpoint URLs injectable for realistic local HTTP coverage tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provider/entra/MsEntraGraphClient.scala | 12 +- .../entra/MsEntraGraphClientTest.scala | 135 ++++++++++++++++-- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala index 93a9944..08b3ca3 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala @@ -54,15 +54,21 @@ trait GraphUsernameResolver { * * @param config Entra config — must have `clientSecret` set; `domains` maps DNS domain → * AB/NetBIOS short name for allow-listing and logging. + * @param tokenEndpointOverride Optional override for the token endpoint, primarily for tests. + * @param graphUsersBaseUrlOverride Optional override for the Graph users base URL, primarily for tests. */ -class MsEntraGraphClient(config: MsEntraConfig) extends GraphUsernameResolver { +class MsEntraGraphClient( + config: MsEntraConfig, + tokenEndpointOverride: Option[String] = None, + graphUsersBaseUrlOverride: Option[String] = None +) extends GraphUsernameResolver { private val logger = LoggerFactory.getLogger(classOf[MsEntraGraphClient]) private val tokenEndpoint = - s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token" + tokenEndpointOverride.getOrElse(s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token") - private val graphUsersBaseUrl = "https://graph.microsoft.com/v1.0/users" + private val graphUsersBaseUrl = graphUsersBaseUrlOverride.getOrElse("https://graph.microsoft.com/v1.0/users") private val domainMap: Map[String, String] = config.domains.getOrElse(Map.empty) diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala index 7eef5f3..0ee8775 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala @@ -16,29 +16,136 @@ package za.co.absa.loginsvc.rest.provider.entra +import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import za.co.absa.loginsvc.rest.config.auth.MsEntraConfig -class MsEntraGraphClientTest extends AnyFlatSpec with Matchers { - - private val client = new MsEntraGraphClient( - MsEntraConfig( - tenantId = "tenant-id", - clientId = "client-id", - clientSecret = None, - audiences = Nil, - domains = Some(Map("corp.dsarena.com" -> "CORP")), - order = 1, - attributes = None +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets + +class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach { + + @volatile private var tokenStatus = 200 + @volatile private var tokenResponseBody = """{"access_token":"graph-token"}""" + @volatile private var graphStatus = 200 + @volatile private var graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"corp.dsarena.com"}""" + @volatile private var lastTokenRequestBody = "" + @volatile private var lastGraphAuthorization = "" + @volatile private var lastGraphPath = "" + @volatile private var lastGraphRawPath = "" + @volatile private var lastGraphQuery = "" + + private var server: HttpServer = _ + private var baseUrl: String = _ + + override protected def beforeAll(): Unit = { + super.beforeAll() + server = HttpServer.create(new InetSocketAddress(0), 0) + server.createContext("/token", new HttpHandler { + override def handle(exchange: HttpExchange): Unit = { + lastTokenRequestBody = new String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8) + respond(exchange, tokenStatus, tokenResponseBody) + } + }) + server.createContext("/v1.0/users", new HttpHandler { + override def handle(exchange: HttpExchange): Unit = { + lastGraphAuthorization = Option(exchange.getRequestHeaders.getFirst("Authorization")).getOrElse("") + lastGraphPath = exchange.getRequestURI.getPath + lastGraphRawPath = exchange.getRequestURI.getRawPath + lastGraphQuery = Option(exchange.getRequestURI.getRawQuery).getOrElse("") + respond(exchange, graphStatus, graphResponseBody) + } + }) + server.start() + baseUrl = s"http://127.0.0.1:${server.getAddress.getPort}" + } + + override protected def afterAll(): Unit = { + if (server != null) server.stop(0) + super.afterAll() + } + + override protected def beforeEach(): Unit = { + tokenStatus = 200 + tokenResponseBody = """{"access_token":"graph-token"}""" + graphStatus = 200 + graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"corp.dsarena.com"}""" + lastTokenRequestBody = "" + lastGraphAuthorization = "" + lastGraphPath = "" + lastGraphRawPath = "" + lastGraphQuery = "" + super.beforeEach() + } + + private def respond(exchange: HttpExchange, status: Int, body: String): Unit = { + val bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(status, bytes.length.toLong) + val os = exchange.getResponseBody + try os.write(bytes) finally os.close() + } + + private def client( + secret: Option[String] = Some("test-secret"), + domains: Option[Map[String, String]] = Some(Map("corp.dsarena.com" -> "CORP")) + ): MsEntraGraphClient = + new MsEntraGraphClient( + MsEntraConfig( + tenantId = "tenant-id", + clientId = "client-id", + clientSecret = secret, + audiences = Nil, + domains = domains, + order = 1, + attributes = None + ), + tokenEndpointOverride = Some(s"$baseUrl/token"), + graphUsersBaseUrlOverride = Some(s"$baseUrl/v1.0/users") ) - ) "MsEntraGraphClient" should "return a lowercase samAccountName without the domain prefix" in { - client.resolveMappedUsername("AB006HM", "corp.dsarena.com", "oto.macenauer@absa.africa") shouldBe Some("ab006hm") + client().resolveUsername("oto.macenauer@absa.africa") shouldBe Some("ab006hm") + lastTokenRequestBody should include("grant_type=client_credentials") + lastTokenRequestBody should include("client_id=client-id") + lastTokenRequestBody should include("client_secret=test-secret") + lastGraphAuthorization shouldBe "Bearer graph-token" + lastGraphPath shouldBe "/v1.0/users/oto.macenauer@absa.africa" + lastGraphRawPath shouldBe "/v1.0/users/oto.macenauer%40absa.africa" + lastGraphQuery shouldBe "$select=onPremisesSamAccountName,onPremisesDomainName" } it should "return None when the user's domain is not in the configured allow-list" in { - client.resolveMappedUsername("AB006HM", "unknown.domain", "oto.macenauer@absa.africa") shouldBe None + graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"unknown.domain"}""" + + client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + } + + it should "return None when Graph does not provide on-premises attributes" in { + graphResponseBody = """{"displayName":"Oto Macenauer"}""" + + client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + } + + it should "return None when the token endpoint response has no access token" in { + tokenResponseBody = """{"token_type":"Bearer"}""" + + client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + } + + it should "return None when the Graph API responds with an error" in { + graphStatus = 500 + graphResponseBody = """{"error":"server exploded"}""" + + client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + } + + it should "return None when the client secret is missing" in { + client(secret = None).resolveUsername("oto.macenauer@absa.africa") shouldBe None + } + + it should "allow direct testing of the mapped username helper" in { + client().resolveMappedUsername("AB006HM", "corp.dsarena.com", "oto.macenauer@absa.africa") shouldBe Some("ab006hm") } } From f507a41de8abe8c07a1de9fff9840450ebacaf89 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Mon, 16 Mar 2026 13:30:57 +0100 Subject: [PATCH 8/9] test: anonymize Entra test identities Replace real-looking sample identifiers in MsEntraGraphClientTest with made-up usernames and email UPNs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../entra/MsEntraGraphClientTest.scala | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala index 0ee8775..0bf1356 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala @@ -30,7 +30,7 @@ class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAft @volatile private var tokenStatus = 200 @volatile private var tokenResponseBody = """{"access_token":"graph-token"}""" @volatile private var graphStatus = 200 - @volatile private var graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"corp.dsarena.com"}""" + @volatile private var graphResponseBody = """{"onPremisesSamAccountName":"UN123XY","onPremisesDomainName":"corp.dsarena.com"}""" @volatile private var lastTokenRequestBody = "" @volatile private var lastGraphAuthorization = "" @volatile private var lastGraphPath = "" @@ -71,7 +71,7 @@ class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAft tokenStatus = 200 tokenResponseBody = """{"access_token":"graph-token"}""" graphStatus = 200 - graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"corp.dsarena.com"}""" + graphResponseBody = """{"onPremisesSamAccountName":"UN123XY","onPremisesDomainName":"corp.dsarena.com"}""" lastTokenRequestBody = "" lastGraphAuthorization = "" lastGraphPath = "" @@ -106,46 +106,46 @@ class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAft ) "MsEntraGraphClient" should "return a lowercase samAccountName without the domain prefix" in { - client().resolveUsername("oto.macenauer@absa.africa") shouldBe Some("ab006hm") + client().resolveUsername("john.smith@example.com") shouldBe Some("un123xy") lastTokenRequestBody should include("grant_type=client_credentials") lastTokenRequestBody should include("client_id=client-id") lastTokenRequestBody should include("client_secret=test-secret") lastGraphAuthorization shouldBe "Bearer graph-token" - lastGraphPath shouldBe "/v1.0/users/oto.macenauer@absa.africa" - lastGraphRawPath shouldBe "/v1.0/users/oto.macenauer%40absa.africa" + lastGraphPath shouldBe "/v1.0/users/john.smith@example.com" + lastGraphRawPath shouldBe "/v1.0/users/john.smith%40example.com" lastGraphQuery shouldBe "$select=onPremisesSamAccountName,onPremisesDomainName" } it should "return None when the user's domain is not in the configured allow-list" in { - graphResponseBody = """{"onPremisesSamAccountName":"AB006HM","onPremisesDomainName":"unknown.domain"}""" + graphResponseBody = """{"onPremisesSamAccountName":"UN123XY","onPremisesDomainName":"unknown.domain"}""" - client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + client().resolveUsername("john.smith@example.com") shouldBe None } it should "return None when Graph does not provide on-premises attributes" in { - graphResponseBody = """{"displayName":"Oto Macenauer"}""" + graphResponseBody = """{"displayName":"John Smith"}""" - client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + client().resolveUsername("john.smith@example.com") shouldBe None } it should "return None when the token endpoint response has no access token" in { tokenResponseBody = """{"token_type":"Bearer"}""" - client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + client().resolveUsername("john.smith@example.com") shouldBe None } it should "return None when the Graph API responds with an error" in { graphStatus = 500 graphResponseBody = """{"error":"server exploded"}""" - client().resolveUsername("oto.macenauer@absa.africa") shouldBe None + client().resolveUsername("john.smith@example.com") shouldBe None } it should "return None when the client secret is missing" in { - client(secret = None).resolveUsername("oto.macenauer@absa.africa") shouldBe None + client(secret = None).resolveUsername("john.smith@example.com") shouldBe None } it should "allow direct testing of the mapped username helper" in { - client().resolveMappedUsername("AB006HM", "corp.dsarena.com", "oto.macenauer@absa.africa") shouldBe Some("ab006hm") + client().resolveMappedUsername("UN123XY", "corp.dsarena.com", "john.smith@example.com") shouldBe Some("un123xy") } } From 3d1de5318b5a781e24b5227400e8fde7911014e6 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Tue, 7 Apr 2026 18:21:13 +0200 Subject: [PATCH 9/9] refactor: move Entra graph/login URLs entirely into configuration Hardcoded Microsoft endpoint URLs have been removed from code. Two new optional fields on MsEntraConfig with public-cloud defaults: loginBaseUrl (default: https://login.microsoftonline.com) graphBaseUrl (default: https://graph.microsoft.com) All derived URLs (token endpoint, Graph users path, Graph scope, OIDC discovery URL, expected JWT issuer) are now computed from these config values. Sovereign-cloud deployments (e.g. Azure Government) can override them without touching code. MsEntraGraphClient constructor overrides (tokenEndpointOverride, graphUsersBaseUrlOverride) are removed; tests now pass custom base URLs via MsEntraConfig directly. Addresses review comment on PR #158. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/resources/example.application.yaml | 6 ++++ .../rest/config/auth/MsEntraConfig.scala | 36 +++++++++++-------- .../provider/entra/MsEntraGraphClient.scala | 12 +++---- .../entra/MsEntraTokenValidator.scala | 4 +-- .../entra/MsEntraGraphClientTest.scala | 10 +++--- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/api/src/main/resources/example.application.yaml b/api/src/main/resources/example.application.yaml index 24727d2..b6f3aed 100644 --- a/api/src/main/resources/example.application.yaml +++ b/api/src/main/resources/example.application.yaml @@ -125,6 +125,12 @@ loginsvc: #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" diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala index 81f6b6c..8c80f85 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/config/auth/MsEntraConfig.scala @@ -22,19 +22,25 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigVali /** * 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 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, @@ -43,7 +49,9 @@ case class MsEntraConfig( audiences: List[String], domains: Option[Map[String, String]] = None, order: Int, - attributes: Option[Map[String, String]] + attributes: Option[Map[String, String]], + loginBaseUrl: String = "https://login.microsoftonline.com", + graphBaseUrl: String = "https://graph.microsoft.com" ) extends ConfigValidatable with ConfigOrdering { def throwErrors(): Unit = diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala index 08b3ca3..809fdec 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClient.scala @@ -54,21 +54,17 @@ trait GraphUsernameResolver { * * @param config Entra config — must have `clientSecret` set; `domains` maps DNS domain → * AB/NetBIOS short name for allow-listing and logging. - * @param tokenEndpointOverride Optional override for the token endpoint, primarily for tests. - * @param graphUsersBaseUrlOverride Optional override for the Graph users base URL, primarily for tests. */ class MsEntraGraphClient( - config: MsEntraConfig, - tokenEndpointOverride: Option[String] = None, - graphUsersBaseUrlOverride: Option[String] = None + config: MsEntraConfig ) extends GraphUsernameResolver { private val logger = LoggerFactory.getLogger(classOf[MsEntraGraphClient]) private val tokenEndpoint = - tokenEndpointOverride.getOrElse(s"https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token") + s"${config.loginBaseUrl}/${config.tenantId}/oauth2/v2.0/token" - private val graphUsersBaseUrl = graphUsersBaseUrlOverride.getOrElse("https://graph.microsoft.com/v1.0/users") + private val graphUsersBaseUrl = s"${config.graphBaseUrl}/v1.0/users" private val domainMap: Map[String, String] = config.domains.getOrElse(Map.empty) @@ -127,7 +123,7 @@ class MsEntraGraphClient( "grant_type" -> "client_credentials", "client_id" -> config.clientId, "client_secret" -> secret, - "scope" -> "https://graph.microsoft.com/.default" + "scope" -> s"${config.graphBaseUrl}/.default" ).map { case (k, v) => URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") } .mkString("&") diff --git a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala index 72b591d..c983135 100644 --- a/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala +++ b/api/src/main/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraTokenValidator.scala @@ -57,10 +57,10 @@ class MsEntraTokenValidator( graphClientOverride.orElse(config.clientSecret.map(_ => new MsEntraGraphClient(config))) private val discoveryUrl = - s"https://login.microsoftonline.com/${config.tenantId}/v2.0/.well-known/openid-configuration" + s"${config.loginBaseUrl}/${config.tenantId}/v2.0/.well-known/openid-configuration" private val expectedIssuer = - s"https://login.microsoftonline.com/${config.tenantId}/v2.0" + s"${config.loginBaseUrl}/${config.tenantId}/v2.0" // Cache the JWKSource keyed by jwks_uri string; refreshes after 1 hour private val jwkSourceCache: LoadingCache[String, JWKSource[NimbusSecurityContext]] = diff --git a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala index 0bf1356..388998a 100644 --- a/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala +++ b/api/src/test/scala/za/co/absa/loginsvc/rest/provider/entra/MsEntraGraphClientTest.scala @@ -43,7 +43,7 @@ class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAft override protected def beforeAll(): Unit = { super.beforeAll() server = HttpServer.create(new InetSocketAddress(0), 0) - server.createContext("/token", new HttpHandler { + server.createContext("/tenant-id/oauth2/v2.0/token", new HttpHandler { override def handle(exchange: HttpExchange): Unit = { lastTokenRequestBody = new String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8) respond(exchange, tokenStatus, tokenResponseBody) @@ -99,10 +99,10 @@ class MsEntraGraphClientTest extends AnyFlatSpec with Matchers with BeforeAndAft audiences = Nil, domains = domains, order = 1, - attributes = None - ), - tokenEndpointOverride = Some(s"$baseUrl/token"), - graphUsersBaseUrlOverride = Some(s"$baseUrl/v1.0/users") + attributes = None, + loginBaseUrl = baseUrl, + graphBaseUrl = baseUrl + ) ) "MsEntraGraphClient" should "return a lowercase samAccountName without the domain prefix" in {