test(ext-oidc): characterise claim matching before the auth-core split

Pins the current behaviour of the role/scope matching that is about to move out
of OidcMiddleware: delimiter set for string claims, whole-entry comparison for
list claims, trimming, empty-requirement semantics under ALL vs ANY, and how a
claim path that walks into a non-map resolves. None of it is OIDC-specific and
none of it was covered directly.
This commit is contained in:
Zakaria El Orche
2026-09-10 18:48:10 +00:00
parent 4feabc45d5
commit ea00182c7c
@@ -0,0 +1,209 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Characterisation tests for claim matching — the part of authorization that has nothing to do
* with OIDC: given a claims map, does the caller hold a role or a scope.
*
* <p>Written to pin the <em>current</em> behaviour, including the edges that are easy to change by
* accident: which characters separate scopes in a string claim, whether a list entry is trimmed
* before comparison, what an empty requirement means under each match mode, and how a claim path
* that walks into a non-map resolves. Every assertion here reflects what the code does today, not
* what it arguably should do.
*/
class ClaimMatchingTest {
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
return new OidcMiddleware(null, OidcConfig
.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build(), null, null);
}
private static OidcMiddleware middleware() {
return middleware("realm_access.roles", "scope,scp");
}
// ── Claim path traversal ─────────────────────────────────────────────────
@Test
void aPathWalksNestedMaps() {
Map<String, Object> claims = Map.of("a", Map.of("b", Map.of("c", List.of("x"))));
assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"}));
}
@Test
void aPathThatWalksIntoANonMapResolvesToNothing() {
// "a" is a string, so "a.b" has nowhere to go — not an error, just no match.
Map<String, Object> claims = Map.of("a", "not-a-map");
assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"}));
}
@Test
void aMissingPathResolvesToNothing() {
assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"}));
}
@Test
void emptySegmentsInAPathAreSkipped() {
// "realm_access..roles" collapses to the same two segments.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void segmentsAreTrimmed() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aBlankRolesPathIsRejectedAtConstruction() {
assertThrows(IllegalStateException.class, () -> middleware(" ", "scope"));
}
@Test
void aNullClaimValueResolvesToNothing() {
Map<String, Object> nested = new HashMap<>();
nested.put("roles", null);
Map<String, Object> claims = Map.of("realm_access", nested);
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Roles: ANY semantics ─────────────────────────────────────────────────
@Test
void anyOneOfTheRequiredRolesIsEnough() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"}));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"}));
}
@Test
void requiringNoRoleAtAllMatchesNothing() {
// The loop never runs, so the answer is false even when the claim is present.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertFalse(middleware().rolesAllowed(claims, new String[0]));
}
// ── What counts as "contains" ────────────────────────────────────────────
@Test
void aListClaimMatchesEntrywiseAndTrimsEachEntry() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aListEntryIsNeverSplitOnDelimiters() {
// Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b".
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("a b")));
assertFalse(middleware().rolesAllowed(claims, new String[]{"a"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"}));
}
@Test
void nullEntriesInAListAreSkipped() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", Arrays.asList(null, "admin")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void anArrayClaimBehavesLikeAList() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", (Object) new String[]{"admin", "user"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}));
}
@Test
void aScalarClaimIsComparedWhole() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", 42));
assertTrue(middleware().rolesAllowed(claims, new String[]{"42"}));
}
@Test
void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() {
for (String separator : List.of(" ", "\t", "\n", "\r", ",")) {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", "admin" + separator + "user"));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}),
"separator " + separator.strip().isEmpty() + " should split the claim");
}
}
@Test
void aStringClaimDoesNotMatchAPrefixOrASubstring() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", "administrator"));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void repeatedDelimitersProduceNoEmptyTokens() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, "));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Scopes: ALL vs ANY, across several claim paths ───────────────────────
@Test
void allRequiresEveryScope() {
Map<String, Object> claims = Map.of("scope", "openid orders:read");
assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void anyRequiresOne() {
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY));
assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY));
}
@Test
void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() {
// The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation,
// which rejects an empty value list before it can ever reach here.
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY));
}
@Test
void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() {
OidcMiddleware mw = middleware("roles", "scope, scp , permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve"));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL));
// ALL is satisfied even when the two scopes come from different claims.
assertTrue(mw.scopesAllowed(claims,
new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL));
}
@Test
void blankScopePathsFallBackToScopeAndScp() {
OidcMiddleware mw = middleware("roles", " ");
assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
@Test
void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() {
OidcMiddleware mw = middleware("roles", " , , ");
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
}