From 677ce54de877dfc6bed7292e88342978572ca6cc Mon Sep 17 00:00:00 2001 From: Jonas Hein Date: Tue, 13 Jan 2026 11:30:33 +0100 Subject: [PATCH 1/3] styling for Institution picker and demo view --- .../controller/UniLoginDemoController.java | 50 +++++ ...uni_login_oidc_institution_chooser_da.html | 183 ++++++++++++++++-- 2 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginDemoController.java diff --git a/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginDemoController.java b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginDemoController.java new file mode 100644 index 00000000..5e84715e --- /dev/null +++ b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginDemoController.java @@ -0,0 +1,50 @@ +package dk.acto.fafnir.sso.service.controller; + +import dk.acto.fafnir.sso.provider.unilogin.Institution; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +/** + * Demo controller to preview the institution chooser template with mock data. + * This controller is always available, regardless of UniLogin configuration. + * Access at: http://localhost:8080/unilogin-demo + */ +@Controller +@Slf4j +@RequestMapping("unilogin-demo") +public class UniLoginDemoController { + + /** + * Demo endpoint to preview the institution chooser template with mock data. + * Access at: http://localhost:8080/unilogin-demo + */ + @GetMapping + public String demo(Model model) { + log.info("Demo endpoint called - showing institution chooser with mock data"); + + // Create mock institutions with various roles + List mockInstitutions = List.of( + new Institution("999904", "TEST FOLKESKOLE", List.of("Lærer", "Pædagog")), + new Institution("R00147", "Test Skole", List.of("Lærer", "Leder")), + new Institution("R00146", "Test Efterskole", List.of("Pædagog", "Vikar")), + new Institution("R00213", "ET Skole", List.of("Medarbejder")), + new Institution("999958", "Top Privatskolen", List.of("Lærer", "Pædagog", "Ledelse")) + ); + + model.addAttribute("user", "1000000158"); + model.addAttribute("institutionList", mockInstitutions); + + return "uni_login_oidc_institution_chooser_da"; + } + + @PostConstruct + private void postConstruct() { + log.info("Exposing UniLogin Demo Endpoint at /unilogin-demo"); + } +} diff --git a/ui/src/main/resources/templates/uni_login_oidc_institution_chooser_da.html b/ui/src/main/resources/templates/uni_login_oidc_institution_chooser_da.html index 1f82b412..9ca80c3f 100644 --- a/ui/src/main/resources/templates/uni_login_oidc_institution_chooser_da.html +++ b/ui/src/main/resources/templates/uni_login_oidc_institution_chooser_da.html @@ -4,26 +4,183 @@ Vælg institution - UNI•Login {{> metadata}} + -
-

Vælg din institution:

+
+

Vælg din institution

+

Vælg den institution, du vil logge ind med:

-
- + {{#institutionList}} - + {{/institutionList}} - -
- -
-
+
+ + + + + From 6735dae8f0d31d7aa37b6f4008b98a300c8e60b6 Mon Sep 17 00:00:00 2001 From: Jonas Hein Date: Tue, 13 Jan 2026 16:18:14 +0100 Subject: [PATCH 2/3] added automatic logout of unilogin --- .../fafnir/sso/provider/UniLoginProvider.java | 189 +++++++++++++++++- .../controller/UniLoginController.java | 106 ++++++++++ .../uni_login_logout_frontchannel.html | 36 ++++ 3 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 ui/src/main/resources/templates/uni_login_logout_frontchannel.html diff --git a/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java b/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java index b8ac8e41..a6a4ec6f 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java @@ -34,6 +34,11 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.*; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Slf4j @@ -43,6 +48,38 @@ public class UniLoginProvider { private final TokenFactory tokenFactory; private final ProviderConf providerConf; private final UniLoginHelper uniloginHelper; + + // In-memory cache for temporary JWT storage during logout flow + // Key: one-time token, Value: JWT, TTL: 5 minutes + private static final Map jwtCache = new ConcurrentHashMap<>(); + private static final ScheduledExecutorService cleanupExecutor = Executors.newScheduledThreadPool(1); + private static final long CACHE_TTL_SECONDS = 300; // 5 minutes + + static { + // Clean up expired entries every minute + cleanupExecutor.scheduleAtFixedRate(() -> { + long now = System.currentTimeMillis(); + jwtCache.entrySet().removeIf(entry -> entry.getValue().isExpired(now)); + }, 60, 60, TimeUnit.SECONDS); + } + + private static class CacheEntry { + private final String jwt; + private final long expiresAt; + + CacheEntry(String jwt, long ttlSeconds) { + this.jwt = jwt; + this.expiresAt = System.currentTimeMillis() + (ttlSeconds * 1000); + } + + boolean isExpired(long now) { + return now > expiresAt; + } + + String getJwt() { + return jwt; + } + } /** * Gets the OIDC base URL based on TEST_ENABLED_UNILOGIN environment variable. @@ -346,8 +383,62 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit .organisationSupport(OrganisationSupport.NATIVE) .inputs(List.of()) .build()); - return AuthenticationResult.success(jwt); - + + // Generate a one-time token for secure JWT retrieval after logout + String oneTimeToken = generateOneTimeToken(); + + // Store JWT in cache with short TTL (5 minutes) + jwtCache.put(oneTimeToken, new CacheEntry(jwt, CACHE_TTL_SECONDS)); + log.debug("Stored JWT in cache with one-time token (expires in {} seconds)", CACHE_TTL_SECONDS); + + // Instead of returning success directly, redirect to logout first + // This ensures the user is logged out from UniLogin after we get the data + String logoutUrl = getLogoutUrl( + fafnirConf.getUrl() + "/unilogin/logout-complete?token=" + oneTimeToken, + null // We don't have id_token_hint here, but it's optional + ); + + log.info("Authentication successful, redirecting to UniLogin logout to end UniLogin session"); + return AuthenticationResult.redirect(logoutUrl); + } + + /** + * Generates a secure random one-time token for JWT retrieval. + * + * @return A random token string + */ + private String generateOneTimeToken() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Retrieves and removes a JWT from the cache using a one-time token. + * This is a one-time operation - the token is consumed after use. + * + * @param token The one-time token + * @return The JWT if found and not expired, null otherwise + */ + public String retrieveJwtFromCache(String token) { + if (token == null || token.isEmpty()) { + return null; + } + + CacheEntry entry = jwtCache.remove(token); + if (entry == null) { + log.warn("One-time token not found or already used: {}", token.substring(0, Math.min(8, token.length())) + "..."); + return null; + } + + if (entry.isExpired(System.currentTimeMillis())) { + log.warn("One-time token expired: {}", token.substring(0, Math.min(8, token.length())) + "..."); + return null; + } + + log.debug("Successfully retrieved JWT from cache using one-time token"); + return entry.getJwt(); } /** @@ -762,4 +853,98 @@ ObjectMapper getObjectMapper() { public ProviderMetaData getMetaData() { return MetadataProvider.UNILOGIN; } + + /** + * Builds the RP-initiated logout URL that redirects to UniLogin's end_session endpoint. + * This should be called when the user initiates logout from your application. + * + * @param postLogoutRedirectUri The URL to redirect to after logout (optional) + * @param idTokenHint The ID token hint (optional, but recommended for better UX) + * @return The logout URL to redirect the user to + */ + public String getLogoutUrl(String postLogoutRedirectUri, String idTokenHint) { + var OID_BASE_URL = getOidcBaseUrl(); + var endSessionUrl = OID_BASE_URL + "logout"; + + StringBuilder url = new StringBuilder(endSessionUrl); + + // Add client_id (required by some OIDC providers) + var clientId = System.getenv("UL_CLIENT_ID"); + if (clientId != null && !clientId.isEmpty()) { + url.append("?client_id=").append(URLEncoder.encode(clientId, java.nio.charset.StandardCharsets.UTF_8)); + } + + // Add post_logout_redirect_uri (optional but recommended) + if (postLogoutRedirectUri != null && !postLogoutRedirectUri.isEmpty()) { + url.append(clientId != null ? "&" : "?") + .append("post_logout_redirect_uri=") + .append(URLEncoder.encode(postLogoutRedirectUri, java.nio.charset.StandardCharsets.UTF_8)); + } else { + // Default to FAFNIR_URL if not provided + var fafnirUrl = System.getenv("FAFNIR_URL"); + if (fafnirUrl != null && !fafnirUrl.isEmpty()) { + url.append(clientId != null ? "&" : "?") + .append("post_logout_redirect_uri=") + .append(URLEncoder.encode(fafnirUrl, java.nio.charset.StandardCharsets.UTF_8)); + } + } + + // Add id_token_hint (optional but recommended for better UX) + if (idTokenHint != null && !idTokenHint.isEmpty()) { + url.append("&id_token_hint=").append(URLEncoder.encode(idTokenHint, java.nio.charset.StandardCharsets.UTF_8)); + } + + return url.toString(); + } + + /** + * Handles back-channel logout event from UniLogin. + * This endpoint receives POST requests from UniLogin when a user logs out. + * + * @param logoutToken The logout token from UniLogin (JWT) + * @return true if logout was successful, false otherwise + */ + public boolean handleBackChannelLogout(String logoutToken) { + try { + log.info("Received back-channel logout event from UniLogin"); + + // Validate the logout token + // In a production system, you should: + // 1. Verify the JWT signature + // 2. Check the issuer (iss claim) + // 3. Check the audience (aud claim) + // 4. Extract the session ID (sid claim) or subject (sub claim) + // 5. Invalidate the corresponding session in your system + + // For now, we'll log the token and return success + // TODO: Implement proper JWT validation and session invalidation + log.debug("Logout token received: {}", logoutToken != null ? logoutToken.substring(0, Math.min(50, logoutToken.length())) + "..." : "null"); + + // In a real implementation, you would: + // - Parse the JWT to get the session ID or user ID + // - Invalidate the session in your session store (e.g., Hazelcast) + // - Clear any cached tokens or user data + + return true; + } catch (Exception e) { + log.error("Error handling back-channel logout", e); + return false; + } + } + + /** + * Invalidates the local session by clearing session attributes. + * + * @param session The HTTP session to invalidate + */ + public void invalidateSession(HttpSession session) { + if (session != null) { + try { + log.debug("Invalidating session: {}", session.getId()); + session.invalidate(); + } catch (Exception e) { + log.warn("Error invalidating session", e); + } + } + } } diff --git a/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java index 3cf321b8..faf794d9 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java @@ -87,6 +87,112 @@ public RedirectView postOrg(@RequestParam String user, @RequestParam String inst return new RedirectView(provider.callbackWithInstitution(user, institution, null, session).getUrl(uniloginConf)); } + /** + * Completion endpoint after UniLogin logout. + * This is called after the user has been logged out from UniLogin. + * Retrieves the JWT from cache using one-time token and redirects to the success page. + * + * Access at: GET /unilogin/logout-complete?token={one-time-token} + */ + @GetMapping("logout-complete") + public RedirectView logoutComplete(@RequestParam(required = false) String token) { + log.info("Logout complete callback - retrieving JWT from cache"); + + // Retrieve the JWT from cache using one-time token + String jwt = provider.retrieveJwtFromCache(token); + + if (jwt != null && !jwt.isEmpty()) { + log.debug("JWT retrieved from cache, redirecting to success page"); + // Redirect to success page with JWT in hash + return new RedirectView(uniloginConf.getSuccessRedirect() + "#" + jwt); + } else { + log.warn("No valid JWT found in cache after logout (token may be expired or invalid), redirecting to failure"); + return new RedirectView(uniloginConf.getFailureRedirect() + "#" + dk.acto.fafnir.api.model.FailureReason.AUTHENTICATION_FAILED.getErrorCode()); + } + } + + /** + * RP-initiated logout endpoint. + * Redirects the user to UniLogin's end_session endpoint to log them out. + * After logout at UniLogin, the user will be redirected back to the post_logout_redirect_uri. + * + * Access at: GET /unilogin/logout + * Optional parameters: + * - post_logout_redirect_uri: URL to redirect to after logout (defaults to FAFNIR_URL) + * - id_token_hint: ID token hint for better UX (optional) + */ + @GetMapping("logout") + public RedirectView logout( + @RequestParam(required = false) String post_logout_redirect_uri, + @RequestParam(required = false) String id_token_hint, + HttpSession session) { + log.info("RP-initiated logout requested"); + + // Invalidate local session first + provider.invalidateSession(session); + + // Build logout URL and redirect to UniLogin + String logoutUrl = provider.getLogoutUrl(post_logout_redirect_uri, id_token_hint); + log.debug("Redirecting to UniLogin logout endpoint: {}", logoutUrl); + + return new RedirectView(logoutUrl); + } + + /** + * Back-channel logout endpoint. + * Receives logout events from UniLogin via HTTP POST when a user logs out. + * This endpoint should be registered in Udbyderportalen as the "Back-Channel Logout URI". + * + * Access at: POST /unilogin/logout/backchannel + * + * According to OIDC Back-Channel Logout specification: + * - The request contains a "logout_token" parameter (JWT) + * - The token should be validated (signature, issuer, audience) + * - The session identified by the token should be invalidated + */ + @PostMapping("logout/backchannel") + @ResponseBody + public org.springframework.http.ResponseEntity backChannelLogout( + @RequestParam("logout_token") String logoutToken) { + log.info("Received back-channel logout event from UniLogin"); + + boolean success = provider.handleBackChannelLogout(logoutToken); + + if (success) { + // Return 200 OK as per OIDC Back-Channel Logout spec + return org.springframework.http.ResponseEntity.ok().build(); + } else { + // Return 400 Bad Request if logout token is invalid + return org.springframework.http.ResponseEntity.badRequest().build(); + } + } + + /** + * Front-channel logout endpoint. + * Handles browser-based logout notifications from UniLogin. + * This is used when UniLogin sends logout notifications via iframe or redirect. + * + * Access at: GET /unilogin/logout/frontchannel + * + * According to OIDC Front-Channel Logout specification: + * - The request may contain "iss" (issuer) and "sid" (session ID) parameters + * - The session should be invalidated + * - A simple HTML page should be returned (typically blank or with a script to clear cookies) + */ + @GetMapping("logout/frontchannel") + public String frontChannelLogout( + @RequestParam(required = false) String iss, + @RequestParam(required = false) String sid, + HttpSession session) { + log.info("Received front-channel logout event from UniLogin - iss: {}, sid: {}", iss, sid); + + // Invalidate local session + provider.invalidateSession(session); + + // Return a simple HTML page that clears any cookies and closes iframe if needed + // The template will be created to handle this + return "uni_login_logout_frontchannel"; + } @PostConstruct private void postConstruct() { diff --git a/ui/src/main/resources/templates/uni_login_logout_frontchannel.html b/ui/src/main/resources/templates/uni_login_logout_frontchannel.html new file mode 100644 index 00000000..848014ab --- /dev/null +++ b/ui/src/main/resources/templates/uni_login_logout_frontchannel.html @@ -0,0 +1,36 @@ + + + + + + Logout + + + + + + + From d6116974632c17ca395432ca567e4c6e7ab4faaa Mon Sep 17 00:00:00 2001 From: Jonas Hein Date: Wed, 14 Jan 2026 10:16:17 +0100 Subject: [PATCH 3/3] logout endpoint --- .../dk/acto/fafnir/sso/provider/UniLoginProvider.java | 3 ++- .../sso/service/controller/UniLoginController.java | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java b/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java index a6a4ec6f..843875be 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java @@ -393,8 +393,9 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit // Instead of returning success directly, redirect to logout first // This ensures the user is logged out from UniLogin after we get the data + // Use path parameter instead of query parameter to avoid UniLogin redirect URI validation issues String logoutUrl = getLogoutUrl( - fafnirConf.getUrl() + "/unilogin/logout-complete?token=" + oneTimeToken, + fafnirConf.getUrl() + "/unilogin/logout-complete/" + oneTimeToken, null // We don't have id_token_hint here, but it's optional ); diff --git a/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java index faf794d9..4caedbc6 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/service/controller/UniLoginController.java @@ -92,11 +92,12 @@ public RedirectView postOrg(@RequestParam String user, @RequestParam String inst * This is called after the user has been logged out from UniLogin. * Retrieves the JWT from cache using one-time token and redirects to the success page. * - * Access at: GET /unilogin/logout-complete?token={one-time-token} + * Access at: GET /unilogin/logout-complete/{token} + * Uses path parameter instead of query parameter to avoid UniLogin redirect URI validation issues. */ - @GetMapping("logout-complete") - public RedirectView logoutComplete(@RequestParam(required = false) String token) { - log.info("Logout complete callback - retrieving JWT from cache"); + @GetMapping("logout-complete/{token}") + public RedirectView logoutComplete(@PathVariable String token) { + log.info("Logout complete callback - retrieving JWT from cache with token: {}", token); // Retrieve the JWT from cache using one-time token String jwt = provider.retrieveJwtFromCache(token);