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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 188 additions & 2 deletions sso/src/main/java/dk/acto/fafnir/sso/provider/UniLoginProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, CacheEntry> 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.
Expand Down Expand Up @@ -346,8 +383,63 @@ 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
// Use path parameter instead of query parameter to avoid UniLogin redirect URI validation issues
String logoutUrl = getLogoutUrl(
fafnirConf.getUrl() + "/unilogin/logout-complete/" + 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();
}

/**
Expand Down Expand Up @@ -762,4 +854,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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,113 @@ 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}
* Uses path parameter instead of query parameter to avoid UniLogin redirect URI validation issues.
*/
@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);

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<String> 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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Institution> 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");
}
}
Loading