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/generate user data #140

Merged
merged 5 commits into from
May 31, 2023
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
31 changes: 30 additions & 1 deletion mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* <p>Example usage:
*
* <pre>{@code
* TokenConfig config = TokenConfig.aTokenConfig().withSubject("subject).build();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this not broken?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure what you mean. If you're talking about the missing quotation mark: yes, that was broken.

* TokenConfig config = TokenConfig.aTokenConfig().withSubjectAndGeneratedUserData("subject").build();
* }</pre>
*/
public class TokenConfig {
Expand All @@ -43,6 +43,7 @@ public class TokenConfig {
@Nonnull private final Instant issuedAt;
@Nonnull private final Instant authenticationTime;
@Nonnull private final Instant expiration;
private final boolean generateUserDataFromSubject;
@Nullable private final Instant notBefore;
@Nullable private final String hostname;
@Nullable private final String realm;
Expand All @@ -61,6 +62,7 @@ private TokenConfig(@Nonnull final Builder builder) {
}
authorizedParty = builder.authorizedParty;
subject = builder.subject;
generateUserDataFromSubject = builder.generateUserDataFromSubject;
scope = String.join(" ", builder.scope);
claims = builder.claims;
realmAccess = builder.realmRoles;
Expand Down Expand Up @@ -114,6 +116,10 @@ public String getSubject() {
return subject;
}

public boolean isGenerateUserDataFromSubject() {
return generateUserDataFromSubject;
}

@Nonnull
public String getScope() {
return scope;
Expand Down Expand Up @@ -211,6 +217,7 @@ public static final class Builder {
@Nonnull private Instant issuedAt = Instant.now();
@Nonnull private Instant expiration = issuedAt.plus(10, ChronoUnit.HOURS);
@Nonnull private Instant authenticationTime = Instant.now();
private boolean generateUserDataFromSubject = false;
@Nullable private Instant notBefore;
@Nullable private String hostname;
@Nullable private String realm;
Expand Down Expand Up @@ -399,13 +406,35 @@ public Builder withAuthorizedParty(@Nonnull final String authorizedParty) {
* @param subject the subject to set
* @return builder
* @see <a href="https://openid.net/specs/openid-connect-core-1_0.html#IDToken">ID token</a>
* @see #withSubjectAndGeneratedUserData(String) to also generate name, preferred username and
* email.
*/
@Nonnull
public Builder withSubject(@Nonnull final String subject) {
this.subject = Objects.requireNonNull(subject);
return this;
}

/**
* Set subject and derive name, preferred username and email.
*
* <p>Note that values explicitly set via {@link #withName(String)}, {@link
* #withGivenName(String)}, {@link #withFamilyName(String)}, {@link
* #withPreferredUsername(String)} or {@link #withEmail(String)} take preference even when using
* this method.
*
* @param subject the subject to set
* @return builder
* @see <a href="https://openid.net/specs/openid-connect-core-1_0.html#IDToken">ID token</a>
* @see #withSubject(String) to only set the subject
*/
@Nonnull
public Builder withSubjectAndGeneratedUserData(@Nonnull final String subject) {
this.subject = Objects.requireNonNull(subject);
this.generateUserDataFromSubject = true;
return this;
}

/**
* Add scope.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package com.tngtech.keycloakmock.impl;

import com.tngtech.keycloakmock.api.TokenConfig;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwt;
import com.tngtech.keycloakmock.impl.session.UserData;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
Expand All @@ -13,6 +11,7 @@
import java.util.Date;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.inject.Inject;
Expand Down Expand Up @@ -56,12 +55,41 @@ public String getToken(
.claim("scope", tokenConfig.getScope())
.claim("typ", "Bearer")
.claim("azp", tokenConfig.getAuthorizedParty());
Optional<UserData> generatedUserData;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use ternary

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yes. But it's not significantly shorter, and the google formatting looks weird:

    Optional<UserData> generatedUserData =
        tokenConfig.isGenerateUserDataFromSubject() ? Optional.of(
            UserData.fromUsernameAndHostname(
                tokenConfig.getSubject(), requestConfiguration.getHostname())) : Optional.empty();

if (tokenConfig.isGenerateUserDataFromSubject()) {
generatedUserData =
Optional.of(
UserData.fromUsernameAndHostname(
tokenConfig.getSubject(), requestConfiguration.getHostname()));
} else {
generatedUserData = Optional.empty();
}
setClaimIfPresent(builder, "nbf", tokenConfig.getNotBefore());
setClaimIfPresent(builder, "name", tokenConfig.getName());
setClaimIfPresent(builder, "given_name", tokenConfig.getGivenName());
setClaimIfPresent(builder, "family_name", tokenConfig.getFamilyName());
setClaimIfPresent(builder, "email", tokenConfig.getEmail());
setClaimIfPresent(builder, "preferred_username", tokenConfig.getPreferredUsername());
setClaimIfPresent(
builder,
"name",
tokenConfig.getName(),
generatedUserData.map(UserData::getName).orElse(null));
setClaimIfPresent(
builder,
"given_name",
tokenConfig.getGivenName(),
generatedUserData.map(UserData::getGivenName).orElse(null));
setClaimIfPresent(
builder,
"family_name",
tokenConfig.getFamilyName(),
generatedUserData.map(UserData::getFamilyName).orElse(null));
setClaimIfPresent(
builder,
"email",
tokenConfig.getEmail(),
generatedUserData.map(UserData::getEmail).orElse(null));
setClaimIfPresent(
builder,
"preferred_username",
tokenConfig.getPreferredUsername(),
generatedUserData.map(UserData::getPreferredUsername).orElse(null));
setClaimIfPresent(builder, "acr", tokenConfig.getAuthenticationContextClassReference());
return builder
.claim("realm_access", tokenConfig.getRealmAccess())
Expand All @@ -71,10 +99,9 @@ public String getToken(
.compact();
}

@SuppressWarnings("unchecked")
public Map<String, Object> parseToken(String token) {
JwtParser parser = Jwts.parserBuilder().setSigningKey(privateKey).build();
return ((Jwt<Header<?>, Claims>) parser.parse(token)).getBody();
return parser.parseClaimsJws(token).getBody();
}

private void setClaimIfPresent(
Expand All @@ -84,6 +111,18 @@ private void setClaimIfPresent(
}
}

private void setClaimIfPresent(
@Nonnull final JwtBuilder builder,
@Nonnull final String claim,
@Nullable String value,
@Nullable String alternative) {
if (value != null) {
Objects.requireNonNull(builder).claim(claim, value);
} else if (alternative != null) {
Objects.requireNonNull(builder).claim(claim, alternative);
}
}

private void setClaimIfPresent(
@Nonnull final JwtBuilder builder, @Nonnull final String claim, @Nullable Instant value) {
if (value != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ public int getPort() {
return port;
}

@Nonnull
public String getHostname() {
return hostname;
}

@Nonnull
public String getRealm() {
return realm;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.tngtech.keycloakmock.impl.session.PersistentSession;
import com.tngtech.keycloakmock.impl.session.SessionRepository;
import com.tngtech.keycloakmock.impl.session.SessionRequest;
import com.tngtech.keycloakmock.impl.session.UserData;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;
import java.util.Arrays;
Expand Down Expand Up @@ -58,11 +59,13 @@ public void handle(@Nonnull RoutingContext routingContext) {
.map(s -> Arrays.asList(s.split(",")))
.orElseGet(Collections::emptyList);

PersistentSession session = request.toSession(username, roles);
sessionRepository.upgradeRequest(request, session);

UrlConfiguration requestConfiguration = routingContext.get(CTX_REQUEST_CONFIGURATION);

PersistentSession session =
request.toSession(
UserData.fromUsernameAndHostname(username, requestConfiguration.getHostname()), roles);
sessionRepository.upgradeRequest(request, session);

routingContext
.response()
.addCookie(redirectHelper.getSessionCookie(session, requestConfiguration))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class LoginRoute implements Handler<RoutingContext> {
public void handle(@Nonnull RoutingContext routingContext) {
// if we have a stored session with a valid token, re-use it
Optional<PersistentSession> existingSession =
Optional.ofNullable(routingContext.getCookie(KEYCLOAK_SESSION_COOKIE))
Optional.ofNullable(routingContext.request().getCookie(KEYCLOAK_SESSION_COOKIE))
.map(Cookie::getValue)
.map(value -> value.split("/"))
.filter(split -> split.length > 0)
Expand All @@ -77,7 +77,7 @@ public void handle(@Nonnull RoutingContext routingContext) {
if (existingSession.isPresent()) {
PersistentSession oldSession = existingSession.get();
PersistentSession newSession =
request.toSession(oldSession.getUsername(), oldSession.getRoles());
request.toSession(oldSession.getUserData(), oldSession.getRoles());
sessionRepository.updateSession(oldSession, newSession);
routingContext
.response()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
@Singleton
public class LogoutRoute implements Handler<RoutingContext> {

private static final String LEGACY_REDIRECT_URI = "redirect_uri";
/**
* <a href="https://github.com/keycloak/keycloak/pull/10887/">Before Keycloak 18</a>, the logout
* endpoint had used the {@value #LEGACY_REDIRECT_URI} query parameter.
*/
private static final String LEGACY_REDIRECT_URI = "redirect_uri";

private static final String POST_LOGOUT_REDIRECT_URI = "post_logout_redirect_uri";

@Nonnull private final SessionRepository sessionRepository;
@Nonnull private final RedirectHelper redirectHelper;

Expand All @@ -46,15 +46,15 @@ public void handle(@Nonnull RoutingContext routingContext) {
UrlConfiguration requestConfiguration = routingContext.get(CTX_REQUEST_CONFIGURATION);
invalidateSession(routingContext);
routingContext
.addCookie(redirectHelper.invalidateSessionCookie(requestConfiguration))
.response()
.addCookie(redirectHelper.invalidateSessionCookie(requestConfiguration))
.putHeader("location", redirectUri)
.setStatusCode(302)
.end();
}

private void invalidateSession(RoutingContext routingContext) {
Optional.ofNullable(routingContext.getCookie(KEYCLOAK_SESSION_COOKIE))
Optional.ofNullable(routingContext.request().getCookie(KEYCLOAK_SESSION_COOKIE))
.map(Cookie::getValue)
.map(s -> s.split("/"))
.filter(s -> s.length > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ private void handlePasswordFlow(RoutingContext routingContext) {
UrlConfiguration requestConfiguration = routingContext.get(CTX_REQUEST_CONFIGURATION);
String password = routingContext.request().getFormAttribute("password");

Session session = AdHocSession.fromClientIdUsernameAndPassword(clientId, username, password);
Session session =
AdHocSession.fromClientIdUsernameAndPassword(
clientId, requestConfiguration.getHostname(), username, password);
String token = tokenHelper.getToken(session, requestConfiguration);

routingContext
Expand Down Expand Up @@ -143,7 +145,8 @@ private void handleClientCredentialsFlow(RoutingContext routingContext) {
final UrlConfiguration requestConfiguration = routingContext.get(CTX_REQUEST_CONFIGURATION);

final Session session =
AdHocSession.fromClientIdUsernameAndPassword(clientId, clientId, password);
AdHocSession.fromClientIdUsernameAndPassword(
clientId, requestConfiguration.getHostname(), clientId, password);

final String token = tokenHelper.getToken(session, requestConfiguration);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.tngtech.keycloakmock.impl.TokenGenerator;
import com.tngtech.keycloakmock.impl.UrlConfiguration;
import com.tngtech.keycloakmock.impl.session.Session;
import com.tngtech.keycloakmock.impl.session.UserData;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -37,14 +38,18 @@ public class TokenHelper {

@Nullable
public String getToken(@Nonnull Session session, @Nonnull UrlConfiguration requestConfiguration) {
UserData userData = session.getUserData();
Builder builder =
aTokenConfig()
.withAuthorizedParty(session.getClientId())
// at the moment, there is no explicit way of setting an audience
.withAudience(session.getClientId())
.withSubject(session.getUsername())
.withPreferredUsername(session.getUsername())
.withFamilyName(session.getUsername())
.withSubject(userData.getSubject())
.withPreferredUsername(userData.getPreferredUsername())
.withGivenName(userData.getGivenName())
.withFamilyName(userData.getFamilyName())
.withName(userData.getName())
.withEmail(userData.getEmail())
.withClaim(SESSION_STATE, session.getSessionId())
// we currently don't do proper authorization anyway, so we can just act as if we were
// compliant to ISO/IEC 29115 level 1 (see KEYCLOAK-3223 / KEYCLOAK-3314)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,34 @@
import javax.annotation.Nullable;

public class AdHocSession implements Session {
@Nonnull private final String username;
@Nonnull private final UserData userData;
@Nonnull private final List<String> roles;
@Nonnull private final String clientId;
@Nonnull private final String sessionId = UUID.randomUUID().toString();

private AdHocSession(
@Nonnull String username, @Nonnull List<String> roles, @Nonnull String clientId) {
this.username = username;
@Nonnull UserData userData, @Nonnull List<String> roles, @Nonnull String clientId) {
this.userData = userData;
this.roles = roles;
this.clientId = clientId;
}

public static AdHocSession fromClientIdUsernameAndPassword(
@Nonnull String clientId, @Nonnull String username, @Nullable String password) {
@Nonnull String clientId,
@Nonnull String hostname,
@Nonnull String username,
@Nullable String password) {
List<String> roles =
Optional.ofNullable(password)
.map(s -> Arrays.asList(s.split(",")))
.orElseGet(Collections::emptyList);
return new AdHocSession(username, roles, clientId);
return new AdHocSession(UserData.fromUsernameAndHostname(username, hostname), roles, clientId);
}

@Nonnull
@Override
public String getUsername() {
return username;
public UserData getUserData() {
return userData;
}

@Nonnull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class PersistentSession implements Session {

@Nonnull private final String clientId;
@Nonnull private final String sessionId;
@Nonnull private final String username;
@Nonnull private final UserData userData;
@Nonnull private final List<String> roles;
@Nullable private final String state;
@Nonnull private final String redirectUri;
Expand All @@ -17,10 +17,10 @@ public class PersistentSession implements Session {
@Nullable private final String nonce;

PersistentSession(
@Nonnull SessionRequest request, @Nonnull String username, @Nonnull List<String> roles) {
@Nonnull SessionRequest request, @Nonnull UserData userData, @Nonnull List<String> roles) {
this.clientId = request.getClientId();
this.sessionId = request.getSessionId();
this.username = username;
this.userData = userData;
this.roles = roles;
this.state = request.getState();
this.redirectUri = request.getRedirectUri();
Expand All @@ -43,8 +43,8 @@ public String getSessionId() {

@Override
@Nonnull
public String getUsername() {
return username;
public UserData getUserData() {
return userData;
}

@Override
Expand Down
Loading