Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/enhance token tooling #73

Merged
merged 2 commits into from
Feb 28, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package de.cuioss.portal.authentication.token;

import de.cuioss.tools.collect.MoreCollections;
import de.cuioss.tools.logging.CuiLogger;
import de.cuioss.tools.string.Splitter;
import io.smallrye.jwt.auth.principal.JWTParser;
Expand Down Expand Up @@ -144,7 +145,7 @@ public boolean providesScopes(Collection<String> expectedScopes) {
* {@link #providesScopes(Collection)} it log on debug the corresponding scopes
*/
public boolean providesScopesAndDebugIfScopesAreMissing(Collection<String> expectedScopes, String logContext,
CuiLogger logger) {
CuiLogger logger) {
Set<String> delta = determineMissingScopes(expectedScopes);
if (delta.isEmpty()) {
logger.trace("All expected scopes are present: {}, {}", expectedScopes, logContext);
Expand Down Expand Up @@ -202,6 +203,24 @@ public boolean hasRole(String expectedRole) {
return getRoles().contains(expectedRole);
}

/**
* @param expectedRoles to be checked
* @return an empty-Set in case the token provides all expectedRoles, otherwise a
* {@link TreeSet} containing all missing roles.
*/
public Set<String> determineMissingRoles(Collection<String> expectedRoles) {
if (MoreCollections.isEmpty(expectedRoles)) {
return Collections.emptySet();
}
Set<String> availableRoles = getRoles();
if (availableRoles.containsAll(expectedRoles)) {
return Collections.emptySet();
}
Set<String> roleDelta = new TreeSet<>(expectedRoles);
roleDelta.removeAll(availableRoles);
return roleDelta;
}

/**
* @return the subject id from the underlying token
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,21 @@ public String getSubject() {
public String getIssuer() {
return jsonWebToken.getIssuer();
}

/**
* Returns the "Not Before" time from the token if present.
* The "nbf" (not before) claim identifies the time before which the JWT must not be accepted for processing.
* This claim is optional, according to the JWT specification (RFC 7519).
*
* @return an {@link Optional} containing the {@link OffsetDateTime} representation of the "Not Before" time
* if the claim is present, or an empty Optional if not
*/
public Optional<OffsetDateTime> getNotBeforeTime() {
Long notBeforeTime = jsonWebToken.getClaim("nbf");
if (notBeforeTime == null) {
return Optional.empty();
}
return Optional.of(OffsetDateTime
.ofInstant(Instant.ofEpochSecond(notBeforeTime), ZoneId.systemDefault()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,44 @@ void shouldHandleNoRoles() {
assertTrue(parsedAccessToken.isPresent(), "Token should be present");
assertTrue(parsedAccessToken.get().getRoles().isEmpty(), "Roles should be empty");
}

@Test
@DisplayName("Should correctly determine missing roles")
void shouldDetermineMissingRoles() {
String initialToken = validSignedJWTWithClaims(SOME_ROLES);
var parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertTrue(parsedAccessToken.isPresent(), "Token should be present");

// Test with existing role
Set<String> missingRoles = parsedAccessToken.get().determineMissingRoles(Set.of("reader"));
assertTrue(missingRoles.isEmpty(), "Should have no missing roles for valid role");

// Test with non-existent role
missingRoles = parsedAccessToken.get().determineMissingRoles(Set.of(DEFINITELY_NO_SCOPE));
assertEquals(1, missingRoles.size(), "Should have one missing role");
assertTrue(missingRoles.contains(DEFINITELY_NO_SCOPE), "Should contain invalid role as missing");

// Test with mixed roles (existing and non-existing)
missingRoles = parsedAccessToken.get().determineMissingRoles(Set.of("reader", DEFINITELY_NO_SCOPE));
assertEquals(1, missingRoles.size(), "Should have one missing role in mixed set");
assertTrue(missingRoles.contains(DEFINITELY_NO_SCOPE), "Should contain invalid role as missing in mixed set");
}

@Test
@DisplayName("Should handle null or empty expected roles")
void shouldHandleNullOrEmptyExpectedRoles() {
String initialToken = validSignedJWTWithClaims(SOME_ROLES);
var parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertTrue(parsedAccessToken.isPresent(), "Token should be present");

// Test with null roles
Set<String> missingRoles = parsedAccessToken.get().determineMissingRoles(null);
assertTrue(missingRoles.isEmpty(), "Should return empty set for null expected roles");

// Test with empty roles
missingRoles = parsedAccessToken.get().determineMissingRoles(Set.of());
assertTrue(missingRoles.isEmpty(), "Should return empty set for empty expected roles");
}
}

@Nested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import static de.cuioss.portal.authentication.token.TestTokenProducer.*;
import java.time.OffsetDateTime;

import static de.cuioss.portal.authentication.token.TestTokenProducer.DEFAULT_TOKEN_PARSER;
import static de.cuioss.portal.authentication.token.TestTokenProducer.SOME_SCOPES;
import static de.cuioss.portal.authentication.token.TestTokenProducer.validSignedJWTWithClaims;
import static de.cuioss.portal.authentication.token.TestTokenProducer.validSignedJWTWithNotBefore;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -109,4 +115,70 @@ void shouldHandleNotExpiredToken() {
assertTrue(token.get().willExpireInSeconds(500), "Token should expire in 500 seconds");
}
}

@Nested
@DisplayName("Not Before Time Tests")
class NotBeforeTimeTests {

@Test
@DisplayName("Should handle token without explicit nbf claim")
void shouldHandleTokenWithoutNotBeforeClaim() {
// Currently smallrye add nbf claim automatically
String initialToken = validSignedJWTWithNotBefore(OffsetDateTime.now().toInstant());

var token = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertTrue(token.isPresent(), "Token should be present for valid input");

// Just verify that the method doesn't throw an exception
// and returns something (either empty or a value)
assertDoesNotThrow(() -> token.get().getNotBeforeTime());

}

@Test
@DisplayName("Should handle token with nbf claim")
void shouldHandleTokenWithNotBeforeClaim() {
// Create a token with nbf set to 5 minutes ago
java.time.Instant notBeforeTime = java.time.Instant.now().minusSeconds(300);
String initialToken = validSignedJWTWithNotBefore(notBeforeTime);

var token = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertTrue(token.isPresent(), "Token should be present for nbf in the past");
var parsedNotBeforeTime = token.get().getNotBeforeTime();
assertTrue(parsedNotBeforeTime.isPresent(), "Not Before Time should be present");
assertTrue(parsedNotBeforeTime.get().isBefore(OffsetDateTime.now()), "Not Before Time should be in the past");

}

@Test
@DisplayName("Should handle token with near future, less than 60 sec nbf claim")
void shouldHandleTokenWithNearFutureNotBeforeClaim() {
// Create a token with nbf set to 30 seconds in the future.
// Smallrye rejects token with nbf in the future starting from 60s.
java.time.Instant notBeforeTime = java.time.Instant.now().plusSeconds(30);
String initialToken = validSignedJWTWithNotBefore(notBeforeTime);

var token = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertTrue(token.isPresent(), "Token should be present for valid input");

var parsedNotBeforeTime = token.get().getNotBeforeTime();
assertTrue(parsedNotBeforeTime.isPresent(), "Not Before Time should be present");

assertTrue(parsedNotBeforeTime.get().isAfter(OffsetDateTime.now()), "Not Before Time should be in the future");

}

@Test
@DisplayName("Should handle token with future, more than 60 sec nbf claim")
void shouldHandleTokenWithFutureNotBeforeClaim() {
// Create a token with nbf set to 300 seconds in the future.
// Smallrye rejects token with nbf in the future starting from 60s.
java.time.Instant notBeforeTime = java.time.Instant.now().plusSeconds(300);
String initialToken = validSignedJWTWithNotBefore(notBeforeTime);

var token = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER);
assertFalse(token.isPresent(), "Token should not be present for valid input");

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ public static String validSignedJWTExpireAt(Instant expireAt) {
.subject(SUBJECT).expiresAt(expireAt).sign(PRIVATE_KEY);
}

/**
* Creates a valid signed JWT with a "Not Before" (nbf) claim
*
* @param notBefore the instant representing the "Not Before" time
* @return a signed JWT token string with the nbf claim set
*/
public static String validSignedJWTWithNotBefore(Instant notBefore) {
return Jwt.claims(SOME_SCOPES).issuer(ISSUER)
.issuedAt(OffsetDateTime.ofInstant(notBefore, ZoneId.systemDefault()).minusMinutes(5).toInstant())
.subject(SUBJECT)
.expiresAt(OffsetDateTime.ofInstant(notBefore, ZoneId.systemDefault()).plusMinutes(10).toInstant())
.claim("nbf", notBefore.getEpochSecond())
.sign(PRIVATE_KEY);
}

@Test
void shouldCreateScopesAndClaims() throws ParseException {
String token = validSignedJWTWithClaims(SOME_SCOPES);
Expand Down
Loading