diff --git a/.gitignore b/.gitignore index 9c47ca2e..0f2d1122 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,6 @@ *.iml out/ -WSDL -resources/wsdl/ dk/acto/auth/generated/ # Gradle diff --git a/sso/build.gradle.kts b/sso/build.gradle.kts index 83be2b92..62b274a1 100644 --- a/sso/build.gradle.kts +++ b/sso/build.gradle.kts @@ -93,11 +93,49 @@ dependencies { tasks.register("downloadWSDLWsiBruger") { src("https://wsibruger.unilogin.dk/wsibruger-v6/ws?WSDL") dest("src/main/resources/wsdl/wsibruger_v6.wsdl") + onlyIf { !dest.exists() } } tasks.register("downloadWSDLWsiInst") { src("https://wsiinst.unilogin.dk/wsiinst-v5/ws?WSDL") dest("src/main/resources/wsdl/wsiinst_v5.wsdl") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdWsCommon") { + src("https://wsibruger.unilogin.dk/wsibruger-v6/ws?xsd=common/ws-common.xsd") + dest("src/main/resources/wsdl/common/ws-common.xsd") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdWsiBruger") { + src("https://wsibruger.unilogin.dk/wsibruger-v6/ws?xsd=wsibruger-ws.xsd") + dest("src/main/resources/wsdl/wsibruger-ws.xsd") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdWsiInst") { + src("https://wsiinst.unilogin.dk/wsiinst-v5/ws?xsd=wsiinst-ws.xsd") + dest("src/main/resources/wsdl/wsiinst-ws.xsd") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdSchemaCommon") { + src("https://wsibruger.unilogin.dk/wsibruger-v6/ws?xsd=common/schema-common.xsd") + dest("src/main/resources/wsdl/common/schema-common.xsd") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdWsibrugerSchema") { + src("https://wsibruger.unilogin.dk/wsibruger-v6/ws?xsd=wsibruger-schema.xsd") + dest("src/main/resources/wsdl/wsibruger-schema.xsd") + onlyIf { !dest.exists() } +} + +tasks.register("downloadXsdWsiinstSchema") { + src("https://wsiinst.unilogin.dk/wsiinst-v5/ws?xsd=wsiinst-schema.xsd") + dest("src/main/resources/wsdl/wsiinst-schema.xsd") + onlyIf { !dest.exists() } } tasks.register("genWsiBruger") { @@ -108,6 +146,10 @@ tasks.register("genWsiBruger") { encoding.set("UTF-8") } dependsOn(tasks.getByName("downloadWSDLWsiBruger")) + dependsOn(tasks.getByName("downloadXsdWsCommon")) + dependsOn(tasks.getByName("downloadXsdSchemaCommon")) + dependsOn(tasks.getByName("downloadXsdWsiBruger")) + dependsOn(tasks.getByName("downloadXsdWsibrugerSchema")) } tasks.register("genWsiInst") { @@ -118,6 +160,9 @@ tasks.register("genWsiInst") { encoding.set("UTF-8") } dependsOn(tasks.getByName("downloadWSDLWsiInst")) + dependsOn(tasks.getByName("downloadXsdWsCommon")) + dependsOn(tasks.getByName("downloadXsdWsiInst")) + dependsOn(tasks.getByName("downloadXsdWsiinstSchema")) } tasks.compileJava { @@ -128,4 +173,10 @@ tasks.compileJava { tasks.processResources { dependsOn(tasks.getByName("downloadWSDLWsiBruger")) dependsOn(tasks.getByName("downloadWSDLWsiInst")) + dependsOn(tasks.getByName("downloadXsdWsCommon")) + dependsOn(tasks.getByName("downloadXsdSchemaCommon")) + dependsOn(tasks.getByName("downloadXsdWsiBruger")) + dependsOn(tasks.getByName("downloadXsdWsibrugerSchema")) + dependsOn(tasks.getByName("downloadXsdWsiInst")) + dependsOn(tasks.getByName("downloadXsdWsiinstSchema")) } 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 132952e1..1337b5dd 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 @@ -144,15 +144,23 @@ public AuthenticationResult callback(UniloginTokenCredentials data, HttpSession accessToken = getAccessToken(accessCode, UL_CLIENT_ID, UL_SECRET, UL_REDIRECT_URL, CODE_VERIFIER, OID_BASE_URL); + // Store ID token in session for use in logout (id_token_hint can help skip confirmation page) + if (accessToken.getId_token() != null && session != null) { + session.setAttribute("id_token", accessToken.getId_token()); + log.debug("Stored ID token in session for logout"); + } + IntrospectionToken intro; intro = getIntrospectToken(accessToken.getAccess_token(), UL_CLIENT_ID, UL_SECRET, OID_BASE_URL); if (intro == null) { + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.intro_failed, "-", session, null); return AuthenticationResult.failure(FailureReason.AUTHENTICATION_FAILED); } var userId = intro.getUniid(); + UniLoginFlowTrace.rememberUniid(session, userId); // Store aktørgruppe from introspection token for role extraction if (intro.getAktoer_gruppe() != null) { @@ -175,21 +183,49 @@ public AuthenticationResult callback(UniloginTokenCredentials data, HttpSession } catch (Exception e) { log.warn("Failed to retrieve extended user information from UserInfo endpoint, falling back to web services: {}", e.getMessage()); } - + + boolean userInfoHadInstitutions = userInfo != null + && userInfo.getInstBrugere() != null + && !userInfo.getInstBrugere().isEmpty(); + int userInfoInstCount = userInfoHadInstitutions ? userInfo.getInstBrugere().size() : 0; + UniLoginFlowTrace.log("callback", + userInfoHadInstitutions ? UniLoginFlowTrace.Branch.userinfo_ok : UniLoginFlowTrace.Branch.userinfo_empty, + userId, session, Map.of("userinfo_inst_count", userInfoInstCount)); + // Fallback to web services if UserInfo failed or returned no data if (institutions == null || institutions.isEmpty()) { log.debug("Using deprecated web service call to get institution list for user: {}", userId); institutions = getInstitutionList(userId); + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.soap_fallback, userId, session, + Map.of("soap_inst_count", institutions.size())); } + int instCountBeforeDedup = institutions.size(); + institutions = deduplicateInstitutions(institutions); + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.dedup_applied, userId, session, Map.of( + "inst_count_before", instCountBeforeDedup, + "inst_count_after", institutions.size(), + "instnr_list", UniLoginFlowTrace.instnrList(institutions))); + if (institutions.isEmpty()) { + if (canIssueJwtWithoutInstitution(session)) { + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.direct_jwt_no_org, userId, session, + Map.of("inst_count", 0)); + return callbackWithoutInstitution(userId, session); + } + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.error_no_institutions, userId, session, + Map.of("inst_count", 0)); return AuthenticationResult.failure(FailureReason.CONNECTION_FAILED); } else if (institutions.size() == 1) { Institution inst = institutions.get(0); log.debug("Single institution found - id: {}, name: {}", inst.id, inst.name); + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.direct_jwt, userId, session, + Map.of("institution_id", inst.id, "inst_count", 1)); return callbackWithInstitution(userId, inst.id, inst.name, session); } else { String chooseInstitutionUrl = uniloginHelper.getChooseInstitutionUrl(userId); + UniLoginFlowTrace.log("callback", UniLoginFlowTrace.Branch.org_picker, userId, session, + Map.of("inst_count", institutions.size(), "instnr_list", UniLoginFlowTrace.instnrList(institutions))); return AuthenticationResult.redirect(chooseInstitutionUrl); } } @@ -216,12 +252,17 @@ public List getInstitutionListFromSession(String userId, HttpSessio userId, userInfo.getInstBrugere().size()); List institutions = convertUserInfoToInstitutions(userInfo, session); log.info("Converted {} institution(s) from UserInfo", institutions.size()); + UniLoginFlowTrace.log("getOrg_data", UniLoginFlowTrace.Branch.userinfo_ok, userId, session, + Map.of("inst_count", institutions.size(), "instnr_list", UniLoginFlowTrace.instnrList(institutions))); return institutions; } } // Fallback to deprecated web service log.warn("UserInfo not available in session, falling back to deprecated web service for user: {}", userId); - return getInstitutionList(userId); + List soapInstitutions = deduplicateInstitutions(getInstitutionList(userId)); + UniLoginFlowTrace.log("getOrg_data", UniLoginFlowTrace.Branch.soap_fallback, userId, session, + Map.of("soap_inst_count", soapInstitutions.size(), "instnr_list", UniLoginFlowTrace.instnrList(soapInstitutions))); + return soapInstitutions; } /** @@ -299,6 +340,36 @@ public String getFailureUrl(FailureReason reason) { } + public boolean canIssueJwtWithoutInstitution(HttpSession session) { + if (session == null) { + return false; + } + return isEmployeeAktorgruppe((String) session.getAttribute("aktoer_gruppe")); + } + + /** + * Issues a JWT without org_id/org_name when the user is an employee but has no institution affiliations. + * Find2Learn can prompt for institution via /license. + */ + public AuthenticationResult callbackWithoutInstitution(String userId, HttpSession session) { + Set roles = getUserRolesFromAktorgruppe(session); + String roleSource = roles.isEmpty() ? "none" : "aktoer_gruppe"; + + String aktoerGruppe = session != null ? (String) session.getAttribute("aktoer_gruppe") : null; + boolean hadCompatibleRole = hasFind2LearnCompatibleEmployeeRole(roles); + roles = ensureEmployeeLoginRole(roles, aktoerGruppe); + if (!hadCompatibleRole && hasFind2LearnCompatibleEmployeeRole(roles)) { + roleSource = "fallback_employee"; + } + + if (roles.isEmpty()) { + log.warn("Cannot issue JWT without institution for user {}: no roles available", userId); + return AuthenticationResult.failure(FailureReason.CONNECTION_FAILED); + } + + return issueJwtAndRedirectToLogout(userId, session, null, null, roles, roleSource); + } + public AuthenticationResult callbackWithInstitution(String userId, String institutionId) { return callbackWithInstitution(userId, institutionId, null, null); } @@ -308,8 +379,6 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit } public AuthenticationResult callbackWithInstitution(String userId, String institutionId, String institutionName, HttpSession session) { - var name = getUserFullNameFromId(userId); - // Try to get institution name from parameter, then from UserInfo in session, otherwise fallback to web service String orgName = (institutionName != null && !institutionName.isEmpty()) ? institutionName : null; @@ -351,17 +420,53 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit // Try to get roles from UserInfo, fallback to aktørgruppe, then web service Set roles = getUserRolesFromUserInfo(userId, institutionId, session); - if (roles.isEmpty()) { - // Try to get role from aktørgruppe (actor group) from introspection token + String roleSource = "none"; + if (!roles.isEmpty()) { + roleSource = "userinfo"; + } else { roles = getUserRolesFromAktorgruppe(session); if (!roles.isEmpty()) { + roleSource = "aktoer_gruppe"; log.debug("Using aktørgruppe from introspection token for user: {} at institution: {}", userId, institutionId); + } else { + log.debug("Using deprecated web service call to get user roles for user: {} at institution: {}", userId, institutionId); + roles = this.getUserRoles(institutionId, userId); + if (!roles.isEmpty()) { + roleSource = "soap"; + } } } - if (roles.isEmpty()) { - log.debug("Using deprecated web service call to get user roles for user: {} at institution: {}", userId, institutionId); - roles = this.getUserRoles(institutionId, userId); + + String aktoerGruppe = session != null ? (String) session.getAttribute("aktoer_gruppe") : null; + boolean hadCompatibleRole = hasFind2LearnCompatibleEmployeeRole(roles); + roles = ensureEmployeeLoginRole(roles, aktoerGruppe); + if (!hadCompatibleRole && hasFind2LearnCompatibleEmployeeRole(roles)) { + roleSource = "none".equals(roleSource) ? "fallback_employee" : roleSource + "+fallback_employee"; + } + + return issueJwtAndRedirectToLogout(userId, session, institutionId, finalOrgName, roles, roleSource); + } + + private AuthenticationResult issueJwtAndRedirectToLogout( + String userId, + HttpSession session, + String institutionId, + String institutionName, + Set roles, + String roleSource) { + var name = getUserFullNameFromId(userId); + List roleStrings = roles.stream().map(UserRole::toString).collect(Collectors.toList()); + log.info("JWT roles for user {} at institution {}: {}", userId, institutionId != null ? institutionId : "-", roleStrings); + + Map jwtTraceFields = new LinkedHashMap<>(); + jwtTraceFields.put("roles", roleStrings); + jwtTraceFields.put("role_source", roleSource); + if (institutionId != null) { + jwtTraceFields.put("institution_id", institutionId); + } else { + jwtTraceFields.put("org_omitted", true); } + UniLoginFlowTrace.log("jwt", UniLoginFlowTrace.Branch.jwt_built, userId, session, jwtTraceFields); var subjectActual = UserData.builder() .subject(userId) @@ -369,7 +474,7 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit .build(); var orgActual = OrganisationData.builder() .organisationId(institutionId) - .organisationName(finalOrgName) + .organisationName(institutionName) .build(); var claimsActual = ClaimData.builder() .claims(roles.stream() @@ -383,29 +488,33 @@ public AuthenticationResult callbackWithInstitution(String userId, String instit .organisationSupport(OrganisationSupport.NATIVE) .inputs(List.of()) .build()); - - // 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 - // Store token in session to retrieve after logout (cookie approach would require HttpServletResponse) + UniLoginFlowTrace.log("jwt", UniLoginFlowTrace.Branch.jwt_cached, userId, session, + Map.of("cache_token_prefix", UniLoginFlowTrace.tokenPrefix(oneTimeToken))); + if (session != null) { session.setAttribute("logout_token", oneTimeToken); log.debug("Stored one-time token in session for post-logout retrieval"); } - - // Use base URL only (no token in URL) to avoid UniLogin redirect URI validation issues + + String idTokenHint = null; + if (session != null) { + idTokenHint = (String) session.getAttribute("id_token"); + if (idTokenHint != null) { + log.debug("Using ID token from session as id_token_hint for logout"); + } + } + String logoutUrl = getLogoutUrl( fafnirConf.getUrl() + "/unilogin/logout-complete", - null // We don't have id_token_hint here, but it's optional + idTokenHint ); - + log.info("Authentication successful, redirecting to UniLogin logout to end UniLogin session"); + UniLoginFlowTrace.log("jwt", UniLoginFlowTrace.Branch.logout_redirect, userId, session, Map.of()); return AuthenticationResult.redirect(logoutUrl); } @@ -429,21 +538,32 @@ private String generateOneTimeToken() { * @return The JWT if found and not expired, null otherwise */ public String retrieveJwtFromCache(String token) { + return retrieveJwtFromCache(token, null, null); + } + + public String retrieveJwtFromCache(String token, String uniid, HttpSession session) { if (token == null || token.isEmpty()) { return null; } - + + String tokenPrefix = UniLoginFlowTrace.tokenPrefix(token); 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())) + "..."); + log.warn("One-time token not found or already used: {}", tokenPrefix + "..."); + UniLoginFlowTrace.log("cache", UniLoginFlowTrace.Branch.logout_complete_cache_miss, + uniid != null ? uniid : UniLoginFlowTrace.uniidFromSession(session), session, + Map.of("cache_token_prefix", tokenPrefix, "reason", "not_found_or_used")); return null; } - + if (entry.isExpired(System.currentTimeMillis())) { - log.warn("One-time token expired: {}", token.substring(0, Math.min(8, token.length())) + "..."); + log.warn("One-time token expired: {}", tokenPrefix + "..."); + UniLoginFlowTrace.log("cache", UniLoginFlowTrace.Branch.logout_complete_cache_miss, + uniid != null ? uniid : UniLoginFlowTrace.uniidFromSession(session), session, + Map.of("cache_token_prefix", tokenPrefix, "reason", "expired")); return null; } - + log.debug("Successfully retrieved JWT from cache using one-time token"); return entry.getJwt(); } @@ -665,7 +785,8 @@ private List convertUserInfoToInstitutions(UserInfoResponse userInf }) .collect(Collectors.toList()); - log.info("Successfully converted {} Institution object(s)", institutions.size()); + institutions = deduplicateInstitutions(institutions); + log.info("Successfully converted {} Institution object(s) after deduplication", institutions.size()); return institutions; } @@ -721,10 +842,13 @@ private Set getUserRolesFromUserInfo(String userId, String institution // Format 1: Direct "roller" array with strings like "PÆDAGOG@EMPLOYEE" if (affiliation.getRoller() != null && !affiliation.getRoller().isEmpty()) { + String aktoerGruppe = (String) session.getAttribute("aktoer_gruppe"); for (String roleString : affiliation.getRoller()) { UserRole role = parseRoleString(roleString); if (role != null) { roles.add(role); + } else if (isEmployeeAktorgruppe(aktoerGruppe)) { + roles.add(UserRole.builder().name(roleString).type("EMPLOYEE").build()); } } } @@ -784,7 +908,11 @@ private UserRole parseRoleString(String roleString) { String name = roleString.substring(0, atIndex); String type = roleString.substring(atIndex + 1); - + + if ("ANSAT".equalsIgnoreCase(type)) { + type = "EMPLOYEE"; + } + return UserRole.builder() .name(name) .type(type) @@ -832,6 +960,13 @@ private Set getUserRolesFromAktorgruppe(HttpSession session) { .build()); log.debug("Extracted role from aktørgruppe: {} -> EMPLOYEE", aktoerGruppe); break; + case "MEDARBEJDER": + roles.add(UserRole.builder() + .name("Medarbejder") + .type("EMPLOYEE") + .build()); + log.debug("Extracted role from aktørgruppe: {} -> Medarbejder@EMPLOYEE", aktoerGruppe); + break; case "EMP_EXTERNAL": case "EKSTERN": roles.add(UserRole.builder() @@ -853,6 +988,85 @@ private Set getUserRolesFromAktorgruppe(HttpSession session) { return roles; } + /** + * Ensures the role set contains at least one employee role that Find2Learn accepts for login. + * Adds Medarbejder@EMPLOYEE when aktoer_gruppe indicates employee but only unusable roles were parsed. + */ + static Set ensureEmployeeLoginRole(Set roles, String aktoerGruppe) { + Set result = roles != null ? new HashSet<>(roles) : new HashSet<>(); + if (hasFind2LearnCompatibleEmployeeRole(result)) { + return result; + } + if (isEmployeeAktorgruppe(aktoerGruppe)) { + result.add(UserRole.builder().name("Medarbejder").type("EMPLOYEE").build()); + log.info("Added Medarbejder@EMPLOYEE fallback role based on aktoer_gruppe: {}", aktoerGruppe); + } + return result; + } + + static boolean hasFind2LearnCompatibleEmployeeRole(Set roles) { + if (roles == null || roles.isEmpty()) { + return false; + } + return roles.stream().anyMatch(UniLoginProvider::isFind2LearnCompatibleEmployeeRole); + } + + static boolean isFind2LearnCompatibleEmployeeRole(UserRole role) { + if (role == null || role.getType() == null) { + return false; + } + String type = role.getType(); + String name = role.getName(); + if ("EMPLOYEE".equalsIgnoreCase(type) || "Medarbejder".equalsIgnoreCase(type)) { + return true; + } + return name != null && "Medarbejder".equalsIgnoreCase(name) && "UNKNOWN".equalsIgnoreCase(type); + } + + static boolean isEmployeeAktorgruppe(String aktoerGruppe) { + if (aktoerGruppe == null || aktoerGruppe.isEmpty()) { + return false; + } + return switch (aktoerGruppe.toUpperCase()) { + case "MEDARBEJDER", "EMPLOYEE", "ANSAT" -> true; + default -> false; + }; + } + + static List deduplicateInstitutions(List institutions) { + if (institutions == null || institutions.isEmpty()) { + return Collections.emptyList(); + } + + Map byId = new LinkedHashMap<>(); + for (Institution inst : institutions) { + if (inst.id == null || inst.id.isBlank()) { + continue; + } + byId.merge(inst.id, inst, (existing, incoming) -> { + List mergedRoles = new ArrayList<>( + existing.roles != null ? existing.roles : Collections.emptyList()); + if (incoming.roles != null) { + for (String role : incoming.roles) { + if (!mergedRoles.contains(role)) { + mergedRoles.add(role); + } + } + } + String name = existing.name != null && !existing.name.isEmpty() + ? existing.name + : incoming.name; + return new Institution(existing.id, name, mergedRoles); + }); + } + + if (byId.size() < institutions.size()) { + log.info("Deduplicated {} institution affiliation(s) to {} unique instnr(s)", + institutions.size(), byId.size()); + } + return new ArrayList<>(byId.values()); + } + ObjectMapper getObjectMapper() { return new ObjectMapper(); } diff --git a/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/InstitutionTilknytning.java b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/InstitutionTilknytning.java index b6caf870..b6b85d10 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/InstitutionTilknytning.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/InstitutionTilknytning.java @@ -1,5 +1,6 @@ package dk.acto.fafnir.sso.provider.unilogin; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -14,9 +15,11 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class InstitutionTilknytning { @JsonProperty("instnr") + @JsonAlias({"inTnr"}) public String inTnr; // Institution number @JsonProperty("instnavn") + @JsonAlias({"inTnavn"}) public String inTnavn; // Institution name // Fields for roles, as they might appear in UserInfo response diff --git a/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTrace.java b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTrace.java new file mode 100644 index 00000000..e863721a --- /dev/null +++ b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTrace.java @@ -0,0 +1,114 @@ +package dk.acto.fafnir.sso.provider.unilogin; + +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +public final class UniLoginFlowTrace { + + public static final String TRACE_UNIID_SESSION_KEY = "trace_uniid"; + + public enum Branch { + intro_failed, + userinfo_ok, + userinfo_empty, + soap_fallback, + dedup_applied, + error_no_institutions, + direct_jwt, + direct_jwt_no_org, + org_picker, + org_empty_list, + org_render, + org_post, + jwt_built, + jwt_cached, + logout_redirect, + logout_complete_no_token, + logout_complete_cache_miss, + logout_complete_success + } + + private UniLoginFlowTrace() { + } + + public static void log(String step, Branch branch, String uniid, HttpSession session, Map extra) { + StringBuilder message = new StringBuilder("UniLogin trace: step=") + .append(step) + .append(" branch=") + .append(branch) + .append(" uniid=") + .append(uniid != null ? uniid : "-") + .append(" session=") + .append(sessionId(session)); + + if (session != null) { + Object aktoerGruppe = session.getAttribute("aktoer_gruppe"); + if (aktoerGruppe != null) { + message.append(" aktoer_gruppe=").append(aktoerGruppe); + } + } + + if (extra != null) { + for (Map.Entry entry : extra.entrySet()) { + message.append(' ').append(entry.getKey()).append('=').append(entry.getValue()); + } + } + + log.info(message.toString()); + } + + public static String sessionId(HttpSession session) { + if (session == null || session.getId() == null) { + return "-"; + } + String id = session.getId(); + return id.length() <= 8 ? id : id.substring(0, 8); + } + + public static boolean hasUserInfoInSession(HttpSession session) { + if (session == null) { + return false; + } + UserInfoResponse userInfo = (UserInfoResponse) session.getAttribute("userInfo"); + return userInfo != null + && userInfo.getInstBrugere() != null + && !userInfo.getInstBrugere().isEmpty(); + } + + public static List instnrList(List institutions) { + if (institutions == null || institutions.isEmpty()) { + return Collections.emptyList(); + } + return institutions.stream() + .map(inst -> inst.id) + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toList()); + } + + public static String tokenPrefix(String token) { + if (token == null || token.isEmpty()) { + return "-"; + } + return token.substring(0, Math.min(8, token.length())); + } + + public static void rememberUniid(HttpSession session, String uniid) { + if (session != null && uniid != null) { + session.setAttribute(TRACE_UNIID_SESSION_KEY, uniid); + } + } + + public static String uniidFromSession(HttpSession session) { + if (session == null) { + return null; + } + Object value = session.getAttribute(TRACE_UNIID_SESSION_KEY); + return value != null ? value.toString() : null; + } +} diff --git a/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UserInfoResponse.java b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UserInfoResponse.java index ca415a42..2bd7f597 100644 --- a/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UserInfoResponse.java +++ b/sso/src/main/java/dk/acto/fafnir/sso/provider/unilogin/UserInfoResponse.java @@ -1,5 +1,6 @@ package dk.acto.fafnir.sso.provider.unilogin; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -14,5 +15,6 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class UserInfoResponse { @JsonProperty("inst_brugere") + @JsonAlias({"instbrugere", "dk:unilogin:instbrugere"}) public List instBrugere; } 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 37485e48..25bc8624 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 @@ -4,6 +4,7 @@ import dk.acto.fafnir.api.model.FailureReason; import dk.acto.fafnir.api.model.conf.FafnirConf; import dk.acto.fafnir.sso.provider.UniLoginProvider; +import dk.acto.fafnir.sso.provider.unilogin.UniLoginFlowTrace; import dk.acto.fafnir.sso.provider.unilogin.UniloginTokenCredentials; import io.vavr.control.Try; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.util.Collections; +import java.util.Map; @Controller @Slf4j @@ -69,11 +71,27 @@ public String getOrg(@RequestParam String user, Model model, HttpServletResponse } if (institutionList.isEmpty()) { + if (provider.canIssueJwtWithoutInstitution(session)) { + log.info("No institutions for employee user {}, issuing JWT without org", user); + UniLoginFlowTrace.log("getOrg", UniLoginFlowTrace.Branch.direct_jwt_no_org, user, session, Map.of( + "inst_count", 0, + "session_has_userInfo", UniLoginFlowTrace.hasUserInfoInSession(session))); + AuthenticationResult result = provider.callbackWithoutInstitution(user, session); + response.sendRedirect(result.getUrl(uniloginConf)); + return null; + } log.warn("No institutions found for user: {}, redirecting to failure URL", user); + UniLoginFlowTrace.log("getOrg", UniLoginFlowTrace.Branch.org_empty_list, user, session, Map.of( + "inst_count", 0, + "session_has_userInfo", UniLoginFlowTrace.hasUserInfoInSession(session))); response.sendRedirect(provider.getFailureUrl(FailureReason.CONNECTION_FAILED)); return null; } else { log.info("Adding {} institution(s) to model for template rendering", institutionList.size()); + UniLoginFlowTrace.log("getOrg", UniLoginFlowTrace.Branch.org_render, user, session, Map.of( + "inst_count", institutionList.size(), + "session_has_userInfo", UniLoginFlowTrace.hasUserInfoInSession(session), + "instnr_list", UniLoginFlowTrace.instnrList(institutionList))); model.addAttribute("user", user); model.addAttribute("institutionList", institutionList); log.info("Model attributes set - user: {}, institutionList size: {}", user, institutionList.size()); @@ -86,6 +104,8 @@ public String getOrg(@RequestParam String user, Model model, HttpServletResponse @ResponseBody public RedirectView postOrg(@RequestParam String user, @RequestParam String institution, HttpSession session, HttpServletResponse response) { + UniLoginFlowTrace.log("org_post", UniLoginFlowTrace.Branch.org_post, user, session, + Map.of("institution", institution)); AuthenticationResult result = provider.callbackWithInstitution(user, institution, null, session); String redirectUrl = result.getUrl(uniloginConf); @@ -97,7 +117,8 @@ public RedirectView postOrg(@RequestParam String user, @RequestParam String inst cookie.setPath("/"); cookie.setMaxAge(300); // 5 minutes cookie.setHttpOnly(true); - cookie.setSecure(true); // Use secure cookies in production + String fafnirUrl = System.getenv("FAFNIR_URL"); + cookie.setSecure(fafnirUrl != null && fafnirUrl.startsWith("https://")); response.addCookie(cookie); log.debug("Set logout token cookie as backup"); } @@ -117,40 +138,52 @@ public RedirectView postOrg(@RequestParam String user, @RequestParam String inst @GetMapping("logout-complete") public RedirectView logoutComplete(HttpSession session, jakarta.servlet.http.HttpServletRequest request) { log.info("Logout complete callback - retrieving JWT from cache"); - + String uniid = UniLoginFlowTrace.uniidFromSession(session); + // Try to get token from session first String token = session != null ? (String) session.getAttribute("logout_token") : null; - + String tokenSource = token != null && !token.isEmpty() ? "session" : null; + // Fallback to cookie if session doesn't have it if ((token == null || token.isEmpty()) && request.getCookies() != null) { for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { if ("fafnir_logout_token".equals(cookie.getName())) { token = cookie.getValue(); + tokenSource = "cookie"; log.debug("Retrieved logout token from cookie"); break; } } } - + if (token == null || token.isEmpty()) { log.warn("No logout token found in session or cookie after logout, redirecting to failure"); + UniLoginFlowTrace.log("logout_complete", UniLoginFlowTrace.Branch.logout_complete_no_token, + uniid, session, Map.of("token_source", "none")); return new RedirectView(uniloginConf.getFailureRedirect() + "#" + dk.acto.fafnir.api.model.FailureReason.AUTHENTICATION_FAILED.getErrorCode()); } - + // Clear the token from session and cookie if (session != null) { session.removeAttribute("logout_token"); } - + // Retrieve the JWT from cache using one-time token - String jwt = provider.retrieveJwtFromCache(token); - + String jwt = provider.retrieveJwtFromCache(token, uniid, session); + if (jwt != null && !jwt.isEmpty()) { - log.debug("JWT retrieved from cache, redirecting to success page"); - // Redirect to success page with JWT in hash + log.info("JWT retrieved from cache, redirecting to success page"); + UniLoginFlowTrace.log("logout_complete", UniLoginFlowTrace.Branch.logout_complete_success, + uniid, session, Map.of( + "token_source", tokenSource != null ? tokenSource : "unknown", + "cache_token_prefix", UniLoginFlowTrace.tokenPrefix(token))); 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"); + UniLoginFlowTrace.log("logout_complete", UniLoginFlowTrace.Branch.logout_complete_cache_miss, + uniid, session, Map.of( + "token_source", tokenSource != null ? tokenSource : "unknown", + "cache_token_prefix", UniLoginFlowTrace.tokenPrefix(token))); return new RedirectView(uniloginConf.getFailureRedirect() + "#" + dk.acto.fafnir.api.model.FailureReason.AUTHENTICATION_FAILED.getErrorCode()); } } diff --git a/sso/src/main/resources/wsdl/common/schema-common.xsd b/sso/src/main/resources/wsdl/common/schema-common.xsd new file mode 100644 index 00000000..a28fe4e2 --- /dev/null +++ b/sso/src/main/resources/wsdl/common/schema-common.xsd @@ -0,0 +1,566 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fejlkoder ved autentifikationsfejl. Sættes også som en subcode i den returnerede SOAP-fault. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Trin angives kun for grupper med typen Hovedgruppe. + + + + + + + + + + + + + + + + + + + + + + + + DT er dagtilbud, 1 svarer til 1. klassetrin i grundskolen, + U1 er første trin på en ungdomsuddannelse, og VU er voksenuddannelse. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Basistype for strenge. + Indholdet må ikke være tomt, og overflødig whitespace fjernes (XSD token). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Uverificeret. + + + + + CPR-verificeret. + + + + + + + + + + + Kontaktpersonen må få adgang til almindelige personoplysninger om den tilknyttede elev. + + + + + + + Kontaktpersonen må få adgang til fortrolige og følsomme personoplysninger om eleven. + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/common/ws-common.xsd b/sso/src/main/resources/wsdl/common/ws-common.xsd new file mode 100644 index 00000000..d8aeaadb --- /dev/null +++ b/sso/src/main/resources/wsdl/common/ws-common.xsd @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsibruger-schema.xsd b/sso/src/main/resources/wsdl/wsibruger-schema.xsd new file mode 100644 index 00000000..b5ffbcd3 --- /dev/null +++ b/sso/src/main/resources/wsdl/wsibruger-schema.xsd @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsibruger-ws.xsd b/sso/src/main/resources/wsdl/wsibruger-ws.xsd new file mode 100644 index 00000000..69564bca --- /dev/null +++ b/sso/src/main/resources/wsdl/wsibruger-ws.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsibruger_v6.wsdl b/sso/src/main/resources/wsdl/wsibruger_v6.wsdl new file mode 100644 index 00000000..803bdc21 --- /dev/null +++ b/sso/src/main/resources/wsdl/wsibruger_v6.wsdl @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ping webservicen med simpelt kald. Returnerer "HelloWorld" hvis webservicen svarer. + + + + + + +Ping webservicen med simpelt kald. Returnerer "helloWorldWithDBAndCredentials" hvis webservicen svarer og brugernavn/password er korrekt angivet. + + + + + + + + +Ping webservicen med simpelt kald. Returnerer "HelloWorld" samt tidspunkt fra databasen hvis webservicen og databasen svarer. + + + + + + +Returnerer brugerens kontaktpersoner på pgd institution. + + + + + + + + +For en kontaktperson bruger, returner en liste over de andre brugere (elever) som vedkommende + er kontaktperson for. Hvis instnr er angivet er det begrænset til kontaktperson-relationen indenfor den + pgd institution + + + + + + + + +For en bruger, returner en liste over de institutioner brugeren er tilknyttet + + + + + + + + +Returnerer en liste af institutionsnumre, hvor der er lavet en dataaftale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsiinst-schema.xsd b/sso/src/main/resources/wsdl/wsiinst-schema.xsd new file mode 100644 index 00000000..987d7eb0 --- /dev/null +++ b/sso/src/main/resources/wsdl/wsiinst-schema.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsiinst-ws.xsd b/sso/src/main/resources/wsdl/wsiinst-ws.xsd new file mode 100644 index 00000000..fae6b5b0 --- /dev/null +++ b/sso/src/main/resources/wsdl/wsiinst-ws.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/main/resources/wsdl/wsiinst_v5.wsdl b/sso/src/main/resources/wsdl/wsiinst_v5.wsdl new file mode 100644 index 00000000..407313be --- /dev/null +++ b/sso/src/main/resources/wsdl/wsiinst_v5.wsdl @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ping webservicen med simpelt kald. Returnerer "HelloWorld" hvis webservicen svarer. + + + + + + +Ping webservicen med simpelt kald. Returnerer "HelloWorldWithDBAndCredentials" hvis webservicen svarer og brugernavn/password er korrekt angivet. + + + + + + + + +Ping webservicen med simpelt kald. Returnerer "HelloWorld" samt tidspunkt fra databasen hvis webservicen og databasen svarer. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Returnerer en liste af institutionsnumre, hvor der er lavet en dataaftale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sso/src/test/java/dk/acto/fafnir/sso/provider/UniLoginRoleMappingTest.java b/sso/src/test/java/dk/acto/fafnir/sso/provider/UniLoginRoleMappingTest.java new file mode 100644 index 00000000..e6e30236 --- /dev/null +++ b/sso/src/test/java/dk/acto/fafnir/sso/provider/UniLoginRoleMappingTest.java @@ -0,0 +1,123 @@ +package dk.acto.fafnir.sso.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dk.acto.fafnir.sso.provider.unilogin.Institution; +import dk.acto.fafnir.sso.provider.unilogin.UserInfoResponse; +import dk.acto.fafnir.sso.provider.unilogin.UserRole; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class UniLoginRoleMappingTest { + + @Test + void ensureEmployeeLoginRole_addsFallbackWhenOnlyLaererUnknown() { + Set roles = Set.of( + UserRole.builder().name("LÆRER").type("UNKNOWN").build() + ); + + Set result = UniLoginProvider.ensureEmployeeLoginRole(roles, "Medarbejder"); + + assertThat(roleStrings(result)).contains("Medarbejder@EMPLOYEE"); + } + + @Test + void ensureEmployeeLoginRole_addsFallbackWhenRolesEmptyAndAktorgruppeMedarbejder() { + Set result = UniLoginProvider.ensureEmployeeLoginRole(Set.of(), "Medarbejder"); + + assertThat(roleStrings(result)).containsExactly("Medarbejder@EMPLOYEE"); + } + + @Test + void ensureEmployeeLoginRole_keepsValidEmployeeRoleWithoutFallback() { + Set roles = Set.of( + UserRole.builder().name("Lærer").type("EMPLOYEE").build() + ); + + Set result = UniLoginProvider.ensureEmployeeLoginRole(roles, "Medarbejder"); + + assertThat(roleStrings(result)).containsExactly("Lærer@EMPLOYEE"); + } + + @Test + void ensureEmployeeLoginRole_acceptsMedarbejderUnknownAsCompatible() { + Set roles = Set.of( + UserRole.builder().name("Medarbejder").type("UNKNOWN").build() + ); + + Set result = UniLoginProvider.ensureEmployeeLoginRole(roles, "Medarbejder"); + + assertThat(roleStrings(result)).containsExactly("Medarbejder@UNKNOWN"); + } + + @Test + void deduplicateInstitutions_mergesSameInstnr() { + List institutions = List.of( + new Institution("12345", "Gladsaxe Skole", List.of("LÆRER@UNKNOWN")), + new Institution("12345", "Gladsaxe Skole", List.of("Medarbejder")) + ); + + List result = UniLoginProvider.deduplicateInstitutions(institutions); + + assertThat(result).hasSize(1); + assertThat(result.get(0).id).isEqualTo("12345"); + assertThat(result.get(0).roles).containsExactlyInAnyOrder("LÆRER@UNKNOWN", "Medarbejder"); + } + + @Test + void deduplicateInstitutions_filtersSingleInvalidId() { + assertThat(UniLoginProvider.deduplicateInstitutions(List.of(new Institution(null, "Skole A", List.of())))) + .isEmpty(); + assertThat(UniLoginProvider.deduplicateInstitutions(List.of(new Institution(" ", "Skole A", List.of())))) + .isEmpty(); + } + + @Test + void deduplicateInstitutions_keepsDistinctInstnr() { + List institutions = List.of( + new Institution("11111", "Skole A", List.of()), + new Institution("22222", "Skole B", List.of()) + ); + + List result = UniLoginProvider.deduplicateInstitutions(institutions); + + assertThat(result).hasSize(2); + } + + @Test + void userInfoResponse_parsesInstbrugereAlias() throws Exception { + String json = """ + {"instbrugere":[{"instnr":"12345","instnavn":"Test Skole","ansat_roller":["Lærer"]}]} + """; + + UserInfoResponse response = new ObjectMapper().readValue(json, UserInfoResponse.class); + + assertThat(response.getInstBrugere()).hasSize(1); + assertThat(response.getInstBrugere().get(0).getInTnr()).isEqualTo("12345"); + assertThat(response.getInstBrugere().get(0).getAnsatRoller()).containsExactly("Lærer"); + } + + @Test + void isEmployeeAktorgruppe_recognizesEmployeeValues() { + assertThat(UniLoginProvider.isEmployeeAktorgruppe("Medarbejder")).isTrue(); + assertThat(UniLoginProvider.isEmployeeAktorgruppe("EMPLOYEE")).isTrue(); + assertThat(UniLoginProvider.isEmployeeAktorgruppe("ANSAT")).isTrue(); + assertThat(UniLoginProvider.isEmployeeAktorgruppe("Elev")).isFalse(); + assertThat(UniLoginProvider.isEmployeeAktorgruppe(null)).isFalse(); + } + + @Test + void fixA_emptyRolesWithMedarbejderAktorgruppe_yieldsCompatibleEmployeeRole() { + Set roles = UniLoginProvider.ensureEmployeeLoginRole(Set.of(), "Medarbejder"); + assertThat(UniLoginProvider.hasFind2LearnCompatibleEmployeeRole(roles)).isTrue(); + assertThat(roleStrings(roles)).contains("Medarbejder@EMPLOYEE"); + } + + private static List roleStrings(Set roles) { + return roles.stream().map(UserRole::toString).sorted().collect(Collectors.toList()); + } +} diff --git a/sso/src/test/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTraceTest.java b/sso/src/test/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTraceTest.java new file mode 100644 index 00000000..0c62ea90 --- /dev/null +++ b/sso/src/test/java/dk/acto/fafnir/sso/provider/unilogin/UniLoginFlowTraceTest.java @@ -0,0 +1,44 @@ +package dk.acto.fafnir.sso.provider.unilogin; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class UniLoginFlowTraceTest { + + @Test + void instnrList_extractsNonBlankIds() { + List institutions = List.of( + new Institution("12345", "Skole A", List.of()), + new Institution("", "Blank", List.of()), + new Institution("67890", "Skole B", List.of()) + ); + + assertThat(UniLoginFlowTrace.instnrList(institutions)).containsExactly("12345", "67890"); + } + + @Test + void instnrList_returnsEmptyForNullOrEmpty() { + assertThat(UniLoginFlowTrace.instnrList(null)).isEmpty(); + assertThat(UniLoginFlowTrace.instnrList(List.of())).isEmpty(); + } + + @Test + void sessionId_returnsPrefixOrDash() { + assertThat(UniLoginFlowTrace.sessionId(null)).isEqualTo("-"); + } + + @Test + void tokenPrefix_returnsPrefixOrDash() { + assertThat(UniLoginFlowTrace.tokenPrefix(null)).isEqualTo("-"); + assertThat(UniLoginFlowTrace.tokenPrefix("")).isEqualTo("-"); + assertThat(UniLoginFlowTrace.tokenPrefix("AbCdEfGhIj")).isEqualTo("AbCdEfGh"); + } + + @Test + void hasUserInfoInSession_returnsFalseWhenSessionNull() { + assertThat(UniLoginFlowTrace.hasUserInfoInSession(null)).isFalse(); + } +}