diff --git a/docker/auth-test/docker-compose.yml b/docker/auth-test/docker-compose.yml index dc830c763..5fe807cec 100644 --- a/docker/auth-test/docker-compose.yml +++ b/docker/auth-test/docker-compose.yml @@ -19,7 +19,7 @@ services: container_name: mock-oauth2 environment: - SERVER_PORT=9090 - - JSON_CONFIG={"interactiveLogin":true,"httpServer":"NettyWrapper","tokenCallbacks":[{"issuerId":"default","tokenExpiry":3600,"requestMappings":[{"requestParam":"grant_type","match":"*","claims":{"sub":"testuser","email":"testuser@example.com","name":"Test User"}}]}]} + - JSON_CONFIG={"interactiveLogin":true,"httpServer":"NettyWrapper","tokenCallbacks":[{"issuerId":"default","tokenExpiry":3600,"requestMappings":[{"requestParam":"grant_type","match":"*","claims":{"sub":"testuser","email":"testuser@example.com","name":"Test User","roles":["Atlas users"]}}]}]} ports: - "9090:9090" networks: @@ -52,7 +52,7 @@ services: - SPRING_FLYWAY_SCHEMAS=webapi - SPRING_FLYWAY_PLACEHOLDERS_OHDSISCHEMA=webapi - SECURITY_PROVIDER=AtlasRegularSecurity - - SECURITY_AUTH_OPENID_ENABLED=true + - SECURITY_AUTH_OIDC_ENABLED=true - SECURITY_AUTH_DB_ENABLED=true - SECURITY_AUTH_LDAP_ENABLED=false - SECURITY_AUTH_AD_ENABLED=false @@ -62,12 +62,14 @@ services: - SECURITY_AUTH_OAUTH_GOOGLE_ENABLED=false - SECURITY_AUTH_OAUTH_FACEBOOK_ENABLED=false - SECURITY_AUTH_OAUTH_GITHUB_ENABLED=false - - SECURITY_AUTH_OPENID_CLIENTID=webapi-client - - SECURITY_AUTH_OPENID_APISECRET=webapi-secret - - SECURITY_AUTH_OPENID_URL=http://mock-oauth2:9090/default/.well-known/openid-configuration - - SECURITY_AUTH_OPENID_EXTERNALURL=http://localhost:9090/default - - SECURITY_AUTH_OPENID_LOGOUTURL=http://localhost:9090/default/endsession - - SECURITY_AUTH_OPENID_EXTRASCOPES=profile email + - SECURITY_AUTH_OIDC_CLIENTID=webapi-client + - SECURITY_AUTH_OIDC_APISECRET=webapi-secret + - SECURITY_AUTH_OIDC_URL=http://mock-oauth2:9090/default/.well-known/openid-configuration + - SECURITY_AUTH_OIDC_EXTERNALURL=http://localhost:9090/default + - SECURITY_AUTH_OIDC_LOGOUTURL=http://localhost:9090/default/endsession + - SECURITY_AUTH_OIDC_EXTRASCOPES=profile email + - SECURITY_AUTH_OIDC_ROLESCLAIM=roles + - SECURITY_DEFAULTROLES=Atlas users - SECURITY_AUTH_OAUTH_CALLBACK_UI=http://localhost:18080/WebAPI/#/welcome - SECURITY_AUTH_OAUTH_CALLBACK_API=http://localhost:18080/WebAPI/user/oauth/callback - SECURITY_AUTH_OAUTH_CALLBACK_URLRESOLVER=query diff --git a/docker/auth-test/postman/auth-tests.postman_collection.json b/docker/auth-test/postman/auth-tests.postman_collection.json index 45d1b5e04..23b2bf38f 100644 --- a/docker/auth-test/postman/auth-tests.postman_collection.json +++ b/docker/auth-test/postman/auth-tests.postman_collection.json @@ -46,22 +46,19 @@ { "name": "OIDC Discovery Endpoint", "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" - ], - "type": "text/javascript" - } - }, { "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", + "pm.test('OIDC discovery endpoint returns 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Discovery document contains required endpoints', function() {", + " const doc = pm.response.json();", + " pm.expect(doc).to.have.property('authorization_endpoint');", + " pm.expect(doc).to.have.property('token_endpoint');", + " pm.expect(doc).to.have.property('issuer');", "});" ], "type": "text/javascript" @@ -86,22 +83,19 @@ { "name": "Auth Providers Endpoint", "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" - ], - "type": "text/javascript" - } - }, { "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", + "pm.test('Auth providers returns 200', function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('OpenID provider is listed', function() {", + " const providers = pm.response.json();", + " const oidc = providers.find(p => p.name === 'OpenID');", + " pm.expect(oidc).to.not.be.undefined;", + " pm.expect(oidc.url).to.equal('user/login/openid');", "});" ], "type": "text/javascript" @@ -196,23 +190,32 @@ { "name": "1. Start OIDC Login", "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" - ], - "type": "text/javascript" - } - }, { "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", - "});" + "pm.test('OIDC login returns 302 redirect', function() {", + " pm.response.to.have.status(302);", + "});", + "", + "const location = pm.response.headers.get('Location');", + "pm.test('Location header contains authorization endpoint', function() {", + " pm.expect(location).to.not.be.undefined;", + " pm.expect(location).to.include('client_id=');", + " pm.expect(location).to.include('state=');", + " pm.expect(location).to.include('response_type=code');", + "});", + "", + "// Extract the state parameter and full auth URL for subsequent requests", + "if (location) {", + " const stateMatch = location.match(/[?&#]state=([^&#]+)/);", + " const state = stateMatch ? stateMatch[1] : null;", + " pm.collectionVariables.set('oidc_state', state);", + " // The mock-oauth2-server's interactive login is at the authorization endpoint", + " // Newman needs the internal Docker URL, so replace localhost with mock-oauth2", + " const internalUrl = location.replace('localhost:9090', 'mock-oauth2:9090');", + " pm.collectionVariables.set('oidc_full_auth_url', internalUrl);", + "}" ], "type": "text/javascript" } @@ -240,23 +243,32 @@ { "name": "2. Simulate IdP Login", "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" - ], - "type": "text/javascript" - } - }, { "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", - "});" + "pm.test('IdP login returns redirect with code', function() {", + " pm.expect(pm.response.code).to.be.oneOf([302, 303]);", + "});", + "", + "const location = pm.response.headers.get('Location');", + "pm.test('Redirect contains authorization code', function() {", + " pm.expect(location).to.not.be.undefined;", + " pm.expect(location).to.include('code=');", + "});", + "", + "// Extract code and state from redirect", + "if (location) {", + " const codeMatch = location.match(/[?&#]code=([^&#]+)/);", + " const stateMatch = location.match(/[?&#]state=([^&#]+)/);", + " const code = codeMatch ? codeMatch[1] : null;", + " const state = stateMatch ? stateMatch[1] : null;", + " pm.collectionVariables.set('oidc_auth_code', code);", + " // State should match what we sent", + " if (state) {", + " pm.collectionVariables.set('oidc_state', state);", + " }", + "}" ], "type": "text/javascript" } @@ -293,23 +305,33 @@ { "name": "3. Complete OAuth Callback", "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" - ], - "type": "text/javascript" - } - }, { "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", - "});" + "pm.test('Callback returns 302 redirect to frontend', function() {", + " pm.response.to.have.status(302);", + "});", + "", + "const location = pm.response.headers.get('Location');", + "pm.test('Redirect contains JWT token', function() {", + " pm.expect(location).to.not.be.undefined;", + " pm.expect(location).to.include('token=');", + "});", + "", + "// Extract JWT from redirect URL", + "if (location) {", + " const tokenMatch = location.match(/[?&#]token=([^&#]+)/);", + " const token = tokenMatch ? tokenMatch[1] : null;", + " if (token) {", + " pm.collectionVariables.set('oidc_jwt_token', token);", + " pm.test('JWT token is well-formed', function() {", + " pm.expect(token).to.include('.');", + " const parts = token.split('.');", + " pm.expect(parts.length).to.equal(3);", + " });", + " }", + "}" ], "type": "text/javascript" } @@ -322,14 +344,15 @@ "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/user/oauth/callback?code={{oidc_auth_code}}&state={{oidc_state}}", + "raw": "{{base_url}}/user/oauth/callback/openid?code={{oidc_auth_code}}&state={{oidc_state}}", "host": [ "{{base_url}}" ], "path": [ "user", "oauth", - "callback" + "callback", + "openid" ], "query": [ { @@ -351,8 +374,13 @@ "listen": "prerequest", "script": { "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" + "const token = pm.collectionVariables.get('oidc_jwt_token');", + "if (token) {", + " pm.request.headers.add({", + " key: 'Authorization',", + " value: 'Bearer ' + token", + " });", + "}" ], "type": "text/javascript" } @@ -361,9 +389,26 @@ "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", - "});" + "const token = pm.collectionVariables.get('oidc_jwt_token');", + "if (!token) {", + " pm.test.skip('No OIDC token available');", + "} else {", + " pm.test('Refresh returns 200 with new JWT', function() {", + " pm.response.to.have.status(200);", + " });", + "", + " const jsonData = pm.response.json();", + " pm.test('Refresh response contains login and jwt', function() {", + " pm.expect(jsonData).to.have.property('login');", + " pm.expect(jsonData).to.have.property('jwt');", + " pm.expect(jsonData.login).to.equal('testuser');", + " });", + "", + " // Update token for subsequent requests", + " if (jsonData.jwt) {", + " pm.collectionVariables.set('oidc_jwt_token', jsonData.jwt);", + " }", + "}" ], "type": "text/javascript" } @@ -391,8 +436,13 @@ "listen": "prerequest", "script": { "exec": [ - "// OIDC tests disabled - skip this request", - "pm.execution.skipRequest();" + "const token = pm.collectionVariables.get('oidc_jwt_token');", + "if (token) {", + " pm.request.headers.add({", + " key: 'Authorization',", + " value: 'Bearer ' + token", + " });", + "}" ], "type": "text/javascript" } @@ -401,9 +451,14 @@ "listen": "test", "script": { "exec": [ - "pm.test('SKIPPED - OIDC not yet implemented', function() {", - " pm.expect(true).to.be.true;", - "});" + "const token = pm.collectionVariables.get('oidc_jwt_token');", + "if (!token) {", + " pm.test.skip('No OIDC token available');", + "} else {", + " pm.test('Protected endpoint accessible with OIDC token', function() {", + " pm.expect(pm.response.code).to.be.oneOf([200, 403]);", + " });", + "}" ], "type": "text/javascript" } diff --git a/docker/integration-test/docker-compose.yml b/docker/integration-test/docker-compose.yml index eabdec711..f93b31a76 100644 --- a/docker/integration-test/docker-compose.yml +++ b/docker/integration-test/docker-compose.yml @@ -79,7 +79,7 @@ services: - SPRING_FLYWAY_SCHEMAS=webapi - SPRING_FLYWAY_PLACEHOLDERS_OHDSISCHEMA=webapi - SECURITY_PROVIDER=AtlasRegularSecurity - - SECURITY_AUTH_OPENID_ENABLED=true + - SECURITY_AUTH_OIDC_ENABLED=true - SECURITY_AUTH_DB_ENABLED=true - SECURITY_AUTH_LDAP_ENABLED=false - SECURITY_AUTH_AD_ENABLED=false @@ -89,12 +89,12 @@ services: - SECURITY_AUTH_OAUTH_GOOGLE_ENABLED=false - SECURITY_AUTH_OAUTH_FACEBOOK_ENABLED=false - SECURITY_AUTH_OAUTH_GITHUB_ENABLED=false - - SECURITY_AUTH_OPENID_CLIENTID=webapi-client - - SECURITY_AUTH_OPENID_APISECRET=${OIDC_CLIENT_SECRET:-webapi-secret} - - SECURITY_AUTH_OPENID_URL=http://mock-oauth2:9090/default/.well-known/openid-configuration - - SECURITY_AUTH_OPENID_EXTERNALURL=http://localhost:9090/default - - SECURITY_AUTH_OPENID_LOGOUTURL=http://localhost:9090/default/endsession - - SECURITY_AUTH_OPENID_EXTRASCOPES=profile email + - SECURITY_AUTH_OIDC_CLIENTID=webapi-client + - SECURITY_AUTH_OIDC_APISECRET=${OIDC_CLIENT_SECRET:-webapi-secret} + - SECURITY_AUTH_OIDC_URL=http://mock-oauth2:9090/default/.well-known/openid-configuration + - SECURITY_AUTH_OIDC_EXTERNALURL=http://localhost:9090/default + - SECURITY_AUTH_OIDC_LOGOUTURL=http://localhost:9090/default/endsession + - SECURITY_AUTH_OIDC_EXTRASCOPES=profile email - SECURITY_AUTH_OAUTH_CALLBACK_UI=http://localhost:18080/WebAPI/#/welcome - SECURITY_AUTH_OAUTH_CALLBACK_API=http://localhost:18080/WebAPI/user/oauth/callback - SECURITY_AUTH_OAUTH_CALLBACK_URLRESOLVER=query diff --git a/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java b/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java index 94264c6ad..19a5921fb 100644 --- a/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java +++ b/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java @@ -50,7 +50,7 @@ public class AuthProviderService { @Value("${security.auth.cas.enabled}") private boolean casAuthEnabled; - @Value("${security.auth.openId.enabled}") + @Value("${security.auth.oidc.enabled}") private boolean openidAuthEnabled; @Value("${security.auth.oauth.facebook.enabled}") @@ -65,7 +65,7 @@ public class AuthProviderService { @Value("${security.auth.saml.enabled}") private boolean samlAuthEnabled; - @Value("${security.auth.openId.logoutUrl:}") + @Value("${security.auth.oidc.logoutUrl:}") private String oidcLogoutUrl; /** diff --git a/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java b/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java index 768b788f2..f506e5579 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java @@ -7,13 +7,13 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; /** @@ -133,4 +133,5 @@ public LoginService.Result login(Authentication authentication) { return loginSvc.onSuccess(authentication); } } + } diff --git a/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java b/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java index b4c23e4d5..78061f3d2 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java @@ -52,24 +52,22 @@ public LoginService( } public Result onSuccess(Authentication authentication) { + String login = authentication.getName().toLowerCase(); + authorizationService.ensureUserExists(login, login, null, this.defaultRoles); + return mintSession(authentication); + } + // Skips ensureUserExists; for callers (e.g. OIDC) that provision the user themselves. + public Result mintSession(Authentication authentication) { String login = authentication.getName().toLowerCase(); - log.info("LoginService: onSuccess: " + login); + log.info("LoginService: mintSession: " + login); String[] roles = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toArray(String[]::new); - // ensure the user exists - authorizationService.ensureUserExists(login, login, null, this.defaultRoles); - - // Generate a unique session ID and store session UUID sessionId = sessionService.createSession(login); - - // Calculate expiration for JWT (same as session) Instant expiresAt = Instant.now().plus(sessionProps.getExpiration()); - - // mint the JWT String jwt = jwtService.generateToken(login, sessionId.toString(), Date.from(expiresAt)); return new Result(login, jwt, roles, "Login successful"); diff --git a/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java b/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java new file mode 100644 index 000000000..83eacfbe4 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java @@ -0,0 +1,294 @@ +package org.ohdsi.webapi.security.authc; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.ohdsi.webapi.security.authz.AuthorizationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.SecurityFilterChain; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Configuration +@ConditionalOnProperty(prefix = "security.auth.oidc", name = "enabled", havingValue = "true") +public class OidcAuthConfig { + + private static final Logger log = LoggerFactory.getLogger(OidcAuthConfig.class); + private static final String REGISTRATION_ID = "openid"; + private static final String DISCOVERY_SUFFIX = "/.well-known/openid-configuration"; + + private final HttpSecurityShared httpSecurityShared; + private final AuthorizationService authorizationService; + private final LoginService loginService; + + @Value("${security.auth.oidc.clientId}") + private String clientId; + + @Value("${security.auth.oidc.apiSecret}") + private String clientSecret; + + @Value("${security.auth.oidc.url}") + private String discoveryOrIssuerUrl; + + @Value("${security.auth.oidc.externalUrl:}") + private String externalUrl; + + @Value("${security.auth.oidc.extraScopes:}") + private String extraScopes; + + @Value("${security.auth.oidc.rolesClaim:}") + private String rolesClaim; + + @Value("${security.auth.oidc.rolesToUpperCase:true}") + private boolean rolesToUpperCase; + + @Value("${security.auth.oauth.callback.api}") + private String callbackApi; + + @Value("${security.auth.oauth.callback.ui}") + private String callbackUi; + + @Value("${security.defaultRoles:}") + private List defaultRoles; + + public OidcAuthConfig(HttpSecurityShared httpSecurityShared, + AuthorizationService authorizationService, + LoginService loginService) { + this.httpSecurityShared = httpSecurityShared; + this.authorizationService = authorizationService; + this.loginService = loginService; + } + + @Bean + public ClientRegistrationRepository oidcClientRegistrationRepository() { + String issuer = stripDiscoverySuffix(discoveryOrIssuerUrl); + log.info("OIDC: Discovering provider metadata from issuer {}", issuer); + + ClientRegistration.Builder builder = ClientRegistrations.fromIssuerLocation(issuer) + .registrationId(REGISTRATION_ID) + .clientId(clientId) + .clientSecret(clientSecret) + .redirectUri(joinPath(callbackApi, REGISTRATION_ID)) + .scope(buildScopes()); + + if (externalUrl != null && !externalUrl.isBlank()) { + String discoveredAuthUri = builder.build().getProviderDetails().getAuthorizationUri(); + String rewritten = rewriteAuthorizationUri(discoveredAuthUri); + builder.authorizationUri(rewritten); + log.info("OIDC: Using external authorization URI {}", rewritten); + } + + return new InMemoryClientRegistrationRepository(builder.build()); + } + + @Bean + @Order(1) + public SecurityFilterChain oidcAuthChain(HttpSecurity http) throws Exception { + httpSecurityShared.configureDefaults(http); + http + .securityMatcher("/user/login/" + REGISTRATION_ID, "/user/oauth/callback/**") + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .oauth2Login(oauth -> oauth + .authorizationEndpoint(authz -> authz.baseUri("/user/login")) + .redirectionEndpoint(redir -> redir.baseUri("/user/oauth/callback/*")) + .successHandler(this::handleSuccess) + .failureHandler((req, res, ex) -> { + log.warn("OIDC: Authentication failed: {}", ex.getMessage()); + res.sendRedirect(appendQueryParam(callbackUi, "error", "oidc_failed")); + })); + return http.build(); + } + + private void handleSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + OidcUser oidcUser = (OidcUser) authentication.getPrincipal(); + // Lowercase so the DB row login matches mintSession's JWT subject (which lowercases via getName()). + String login = oidcUser.getSubject().toLowerCase(); + String name = firstNonBlank(oidcUser.getFullName(), oidcUser.getEmail(), login); + + log.info("OIDC: Authenticated user sub={}", login); + + List filteredDefaults = defaultRoles.stream().filter(s -> !s.isBlank()).toList(); + authorizationService.ensureUserExists(login, name, UserOrigin.OIDC, filteredDefaults); + + List idpRoles = extractRoles(oidcUser.getClaims(), rolesClaim, rolesToUpperCase); + if (!idpRoles.isEmpty()) { + log.info("OIDC: Syncing roles from token for user {}: {}", login, idpRoles); + syncRoles(login, idpRoles); + } + + List authorities = idpRoles.stream() + .map(SimpleGrantedAuthority::new) + .toList(); +Authentication wrapped = new UsernamePasswordAuthenticationToken(login, null, authorities); + // mintSession — not onSuccess — so onSuccess's ensureUserExists doesn't overwrite the display name. + LoginService.Result result = loginService.mintSession(wrapped); + + response.sendRedirect(appendFragmentParam(callbackUi, "token", result.jwt())); + } + + private void syncRoles(String login, List idpRoles) { + List currentOidcRoleNames; + try { + currentOidcRoleNames = authorizationService.getOidcOriginRoles(login); + } catch (Exception e) { + log.warn("OIDC: Could not fetch OIDC-origin roles for user {}: {}", login, e.getMessage()); + return; + } + + for (String roleName : idpRoles) { + if (!currentOidcRoleNames.contains(roleName)) { + try { + authorizationService.addUserToRole(roleName, login, UserOrigin.OIDC); + log.info("OIDC: Added role '{}' to user '{}'", roleName, login); + } catch (Exception e) { + log.warn("OIDC: Could not add role '{}' to user '{}': {}", roleName, login, e.getMessage()); + } + } + } + + for (String roleName : currentOidcRoleNames) { + if (!idpRoles.contains(roleName)) { + try { + authorizationService.removeUserFromRole(roleName, login, UserOrigin.OIDC); + log.info("OIDC: Removed role '{}' from user '{}'", roleName, login); + } catch (Exception e) { + log.warn("OIDC: Could not remove role '{}' from user '{}': {}", roleName, login, e.getMessage()); + } + } + } + } + + private Set buildScopes() { + Set scopes = new LinkedHashSet<>(); + scopes.add("openid"); + scopes.add("profile"); + scopes.add("email"); + if (extraScopes != null && !extraScopes.isBlank()) { + for (String s : extraScopes.trim().split("\\s+")) { + if (!s.isBlank()) { + scopes.add(s); + } + } + } + return scopes; + } + + private static String joinPath(String base, String segment) { + return (base.endsWith("/") ? base : base + "/") + segment; + } + + private static String appendQueryParam(String url, String key, String value) { + // Insert before any URL fragment so SPA hash-route callbacks keep the param queryable. + int fragmentIdx = url.indexOf('#'); + String base = fragmentIdx >= 0 ? url.substring(0, fragmentIdx) : url; + String fragment = fragmentIdx >= 0 ? url.substring(fragmentIdx) : ""; + String separator = base.contains("?") ? "&" : "?"; + return base + separator + key + "=" + value + fragment; + } + + private static String appendFragmentParam(String url, String key, String value) { + int fragmentIdx = url.indexOf('#'); + if (fragmentIdx < 0) { + return url + "#" + key + "=" + value; + } + String base = url.substring(0, fragmentIdx); + String fragment = url.substring(fragmentIdx + 1); + String separator = fragment.isEmpty() ? "" : "&"; + return base + "#" + fragment + separator + key + "=" + value; + } + + private String stripDiscoverySuffix(String url) { + if (url == null || url.isBlank()) { + throw new IllegalStateException("security.auth.oidc.url must be configured when OIDC is enabled"); + } + if (url.endsWith(DISCOVERY_SUFFIX)) { + return url.substring(0, url.length() - DISCOVERY_SUFFIX.length()); + } + return url; + } + + private String rewriteAuthorizationUri(String authorizationUri) { + try { + URI authUri = URI.create(authorizationUri); + URI extUri = URI.create(externalUrl); + String discoveryBase = stripDiscoverySuffix(discoveryOrIssuerUrl); + URI discoveryBaseUri = URI.create(discoveryBase); + String pathSuffix = authUri.getPath().substring(discoveryBaseUri.getPath().length()); + String extBase = extUri.toString(); + if (extBase.endsWith("/")) { + extBase = extBase.substring(0, extBase.length() - 1); + } + return extBase + pathSuffix; + } catch (Exception e) { + log.warn("OIDC: Could not rewrite authorization URI with externalUrl '{}', using discovered value '{}'", + externalUrl, authorizationUri, e); + return authorizationUri; + } + } + + private static String firstNonBlank(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } + + static List extractRoles(Map claims, String claimPath, boolean toUpperCase) { + if (claimPath == null || claimPath.isBlank() || claims == null) { + return List.of(); + } + String[] parts = claimPath.split("\\."); + Object current = claims; + for (String part : parts) { + if (current instanceof Map map) { + current = map.get(part); + } else { + log.warn("OIDC: Cannot traverse claim path '{}' - intermediate value is not a map", claimPath); + return List.of(); + } + if (current == null) { + log.debug("OIDC: Claim '{}' not found in ID token", claimPath); + return List.of(); + } + } + + if (current instanceof List list) { + List roles = new ArrayList<>(); + for (Object item : list) { + if (item instanceof String s) { + roles.add(toUpperCase ? s.toUpperCase() : s); + } + } + return roles; + } + if (current instanceof String s) { + return List.of(toUpperCase ? s.toUpperCase() : s); + } + log.warn("OIDC: Claim '{}' is not a list or string: {}", claimPath, current.getClass().getName()); + return List.of(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authc/UserOrigin.java b/src/main/java/org/ohdsi/webapi/security/authc/UserOrigin.java index 960fb7676..1d9bd51f0 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/UserOrigin.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/UserOrigin.java @@ -3,7 +3,7 @@ import org.ohdsi.webapi.security.provisioning.model.LdapProviderType; public enum UserOrigin { - SYSTEM, AD, LDAP, WINDOWS, KERBEROS, GOOGLE, FACEBOOK, DATABASE; + SYSTEM, AD, LDAP, WINDOWS, KERBEROS, GOOGLE, FACEBOOK, DATABASE, OIDC; public static UserOrigin getFrom(LdapProviderType ldapProviderType) { switch (ldapProviderType) { diff --git a/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java b/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java index 3add4dff4..7af85000e 100644 --- a/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java +++ b/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java @@ -175,6 +175,18 @@ public void removeUserFromRole(String roleName, String login, UserOrigin origin) this.roleService.removeUserFromRole(login, roleName, origin); } + // @Transactional required: UserEntity.userRoles is FetchType.LAZY and getUserByLogin closes its own txn. + @Transactional(readOnly = true) + public List getOidcOriginRoles(String login) { + UserEntity user = userService.getUserByLogin(login).orElseThrow(); + return user.getUserRoles().stream() + .filter(ur -> ur.getOrigin() == UserOrigin.OIDC) + .map(ur -> ur.getRole()) + .filter(role -> Boolean.TRUE.equals(role.isSystemRole())) + .map(role -> role.getName()) + .toList(); + } + public void addUserToRole(String roleName, String login, UserOrigin origin) { this.roleService.addUserToRole(login, roleName, origin); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cd26fbc6a..35b5b2d79 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -217,7 +217,7 @@ security: apiKey: "" apiSecret: "" - openId: # OpenID + oidc: # OpenID Connect enabled: false apiSecret: "" clientId: "" @@ -226,6 +226,7 @@ security: extraScopes: "" logoutUrl: "" redirectUrl: http://localhost/index.html#/welcome/ + rolesClaim: "" url: "" saml: # SAML (Security Assertion Markup Language)