Skip to content

Commit

Permalink
Add OIDC SecurityEvent
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Sep 30, 2020
1 parent 67fdafe commit 4e15e67
Show file tree
Hide file tree
Showing 15 changed files with 213 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,31 @@ If UserInfo is the source of the roles then set `quarkus.oidc.authentication.use

Additionally a custom `SecurityIdentityAugmentor` can also be used to add the roles as documented link:security#security-identity-customization[here].

== Listening to important authentication events

One can register `@ApplicationScoped` bean which will observe important OIDC authentication events. The listener will be updated when a user has logged in for the first time or re-authenticated, as well as when the session has been refreshed. More events may be reported in the future. For example:

[source, java]
----
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.vertx.ext.web.RoutingContext;
@ApplicationScoped
public class SecurityEventListener {
public void event(@Observes SecurityEvent event) {
String tenantId = event.getSecurityIdentity().getAttribute("tenant-id");
RoutingContext vertxContext = event.getSecurityIdentity().getCredential(IdTokenCredential.class).getRoutingContext();
vertxContext.put("listener-message", String.format("event:%s,tenantId:%s", event.getEventType().name(), tenantId));
}
}
----

== Single Page Applications

Please check if implementing SPAs the way it is suggested in the link:security-openid-connect#single-page-applications[Single Page Applications for Service Applications] section can meet your requirements.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package io.quarkus.oidc.deployment;

import java.util.Collection;
import java.util.function.BooleanSupplier;

import javax.inject.Singleton;

import org.eclipse.microprofile.jwt.Claim;
import org.jboss.jandex.DotName;

import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.arc.deployment.ValidationPhaseBuildItem;
import io.quarkus.arc.processor.BuildExtension;
import io.quarkus.arc.processor.ObserverInfo;
import io.quarkus.deployment.Capabilities;
import io.quarkus.deployment.Capability;
import io.quarkus.deployment.Feature;
Expand All @@ -18,6 +23,7 @@
import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.runtime.DefaultTenantConfigResolver;
import io.quarkus.oidc.runtime.OidcAuthenticationMechanism;
import io.quarkus.oidc.runtime.OidcBuildTimeConfig;
Expand All @@ -35,6 +41,7 @@
import io.smallrye.jwt.build.impl.JwtProviderImpl;

public class OidcBuildStep {
public static final DotName DOTNAME_SECURITY_EVENT = DotName.createSimple(SecurityEvent.class.getName());

OidcBuildTimeConfig buildTimeConfig;

Expand Down Expand Up @@ -90,6 +97,18 @@ public SyntheticBeanBuildItem setup(
.done();
}

@BuildStep(onlyIf = IsEnabled.class)
@Record(ExecutionTime.RUNTIME_INIT)
public void findSecurityEventObservers(
OidcRecorder recorder,
ValidationPhaseBuildItem validationPhase) {
Collection<ObserverInfo> observers = validationPhase.getContext().get(BuildExtension.Key.OBSERVERS);
boolean isSecurityEventObserved = observers.stream()
.anyMatch(observer -> observer.asObserver().getObservedType().name().equals(DOTNAME_SECURITY_EVENT));
recorder.setSecurityEventObserved(isSecurityEventObserved);

}

static class IsEnabled implements BooleanSupplier {
OidcBuildTimeConfig config;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1011,9 +1011,8 @@ public static class Proxy {

public static enum ApplicationType {
/**
* A {@code WEB_APP} is a client that server pages, usually a frontend application. For this type of client the
* Authorization Code Flow is
* defined as the preferred method for authenticating users.
* A {@code WEB_APP} is a client that serves pages, usually a frontend application. For this type of client the
* Authorization Code Flow is defined as the preferred method for authenticating users.
*/
WEB_APP,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.quarkus.oidc;

import io.quarkus.security.identity.SecurityIdentity;

/**
* Security event.
*
*/
public class SecurityEvent {
public enum Type {
/**
* OIDC Login event which is reported after the first user authentication but also when the user's session
* has expired and the user has re-authenticated at the OIDC provider site.
*/
OIDC_LOGIN,
/**
* OIDC Session refreshed event is reported if it has been detected that an ID token will expire shortly and the session
* has been successfully auto-refreshed without the user having to re-authenticate again at the OIDC site.
*/
OIDC_SESSION_REFRESHED,
/**
* OIDC Session expired and refreshed event is reported if a session has expired but been successfully refreshed
* without the user having to re-authenticate again at the OIDC site.
*/
OIDC_SESSION_EXPIRED_AND_REFRESHED,
/**
* OIDC Logout event is reported when the current user has started an RP-initiated OIDC logout flow.
*/
OIDC_LOGOUT_RP_INITIATED
}

private final Type eventType;
private final SecurityIdentity securityIdentity;

public SecurityEvent(Type eventType, SecurityIdentity securityIdentity) {
this.eventType = eventType;
this.securityIdentity = securityIdentity;
}

public Type getEventType() {
return eventType;
}

public SecurityIdentity getSecurityIdentity() {
return securityIdentity;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
import org.jboss.logging.Logger;

import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.arc.Arc;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.Authentication;
import io.quarkus.oidc.OidcTenantConfig.Credentials;
import io.quarkus.oidc.OidcTenantConfig.Credentials.Secret;
import io.quarkus.oidc.RefreshToken;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.security.AuthenticationCompletionException;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.AuthenticationRedirectException;
Expand Down Expand Up @@ -100,6 +102,7 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
@Override
public SecurityIdentity apply(SecurityIdentity identity) {
if (isLogout(context, configContext)) {
updateListener(SecurityEvent.Type.OIDC_LOGOUT_RP_INITIATED, identity);
throw redirectToLogoutEndpoint(context, configContext, idToken);
}

Expand Down Expand Up @@ -130,12 +133,16 @@ public SecurityIdentity apply(Throwable throwable) {
if (identity == null) {
LOG.debug("SecurityIdentity is null after a token refresh");
throw new AuthenticationCompletionException();
} else {
updateListener(SecurityEvent.Type.OIDC_SESSION_EXPIRED_AND_REFRESHED, identity);
}
} else {
identity = trySilentRefresh(configContext, refreshToken, context, identityProviderManager);
if (identity == null) {
LOG.debug("ID token can no longer be refreshed, using the current SecurityIdentity");
identity = ((TokenAutoRefreshException) throwable).getSecurityIdentity();
} else {
updateListener(SecurityEvent.Type.OIDC_SESSION_REFRESHED, identity);
}
}
return identity;
Expand Down Expand Up @@ -390,6 +397,11 @@ private void processSuccessfulAuthentication(RoutingContext context,
maxAge += configContext.oidcConfig.authentication.sessionAgeExtension.getSeconds();
}
createCookie(context, configContext, getSessionCookieName(configContext), cookieValue, maxAge);
updateListener(SecurityEvent.Type.OIDC_LOGIN, securityIdentity);
}

private void updateListener(SecurityEvent.Type eventType, SecurityIdentity securityIdentity) {
Arc.container().beanManager().fireEvent(new SecurityEvent(eventType, securityIdentity));
}

private String getRedirectPath(TenantConfigContext configContext, RoutingContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class OidcAuthenticationMechanism implements HttpAuthenticationMechanism

@Inject
DefaultTenantConfigResolver resolver;

private BearerAuthenticationMechanism bearerAuth = new BearerAuthenticationMechanism();
private CodeAuthenticationMechanism codeAuth = new CodeAuthenticationMechanism();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request,
AuthenticationRequestContext context) {
OidcTokenCredential credential = (OidcTokenCredential) request.getToken();
RoutingContext vertxContext = credential.getRoutingContext();
vertxContext.put(AuthenticationRequestContext.class.getName(), context);
return Uni.createFrom().deferred(new Supplier<Uni<? extends SecurityIdentity>>() {
@Override
public Uni<SecurityIdentity> get() {
Expand Down Expand Up @@ -159,6 +160,8 @@ public String getName() {
if (userInfo != null) {
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig, userInfo);
}
OidcUtils.setBlockinApiAttribute(builder, vertxContext);
OidcUtils.setTenantIdAttribute(builder, resolvedContext.oidcConfig);
uniEmitter.complete(builder.build());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import org.jboss.logging.Logger;

import io.quarkus.arc.Arc;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
Expand Down Expand Up @@ -333,4 +334,10 @@ protected static Optional<ProxyOptions> toProxyOptions(OidcTenantConfig.Proxy pr
}
return Optional.of(new ProxyOptions(jsonOptions));
}

public void setSecurityEventObserved(boolean isSecurityEventObserved) {
TenantConfigBean bean = Arc.container().instance(TenantConfigBean.class).get();
bean.setSecurityEventObserved(isSecurityEventObserved);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.ForbiddenException;
import io.quarkus.security.credential.TokenCredential;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
Expand Down Expand Up @@ -176,6 +177,8 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(
builder.setPrincipal(jwtPrincipal);
setSecurityIdentityRoles(builder, config, rolesJson);
setSecurityIdentityUserInfo(builder, userInfo);
setBlockinApiAttribute(builder, vertxContext);
setTenantIdAttribute(builder, config);
return builder.build();
}

Expand All @@ -191,6 +194,17 @@ public static void setSecurityIdentityRoles(QuarkusSecurityIdentity.Builder buil
}
}

public static void setBlockinApiAttribute(QuarkusSecurityIdentity.Builder builder, RoutingContext vertxContext) {
if (vertxContext != null) {
builder.addAttribute(AuthenticationRequestContext.class.getName(),
vertxContext.get(AuthenticationRequestContext.class.getName()));
}
}

public static void setTenantIdAttribute(QuarkusSecurityIdentity.Builder builder, OidcTenantConfig config) {
builder.addAttribute("tenant-id", config.tenantId.orElse("Default"));
}

public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, JsonObject userInfo) {
if (userInfo != null) {
builder.addAttribute("userinfo", new UserInfo(userInfo.encode()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class TenantConfigBean {
private final Map<String, TenantConfigContext> staticTenantsConfig;
private final TenantConfigContext defaultTenant;
private final Function<OidcTenantConfig, TenantConfigContext> tenantConfigContextFactory;
private boolean securityEventObserved;

public TenantConfigBean(Map<String, TenantConfigContext> staticTenantsConfig, TenantConfigContext defaultTenant,
Function<OidcTenantConfig, TenantConfigContext> tenantConfigContextFactory) {
Expand All @@ -29,4 +30,12 @@ public TenantConfigContext getDefaultTenant() {
public Function<OidcTenantConfig, TenantConfigContext> getTenantConfigContextFactory() {
return tenantConfigContextFactory;
}

boolean isSecurityEventObserved() {
return securityEventObserved;
}

void setSecurityEventObserved(boolean securityEventObserved) {
this.securityEventObserved = securityEventObserved;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public String resolve(RoutingContext context) {
return "tenant-logout";
}

if (path.contains("tenant-listener")) {
return "tenant-listener";
}

if (path.contains("tenant-autorefresh")) {
return "tenant-autorefresh";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,22 @@ public String refresh() {
if (!accessTokenCredential.getRefreshToken().getToken().equals(refreshToken.getToken())) {
throw new OIDCException("Refresh token values are not equal");
}
return refreshToken.getToken() != null && !refreshToken.getToken().isEmpty() ? "RT injected" : "no refresh";
if (refreshToken.getToken() != null && !refreshToken.getToken().isEmpty()) {
String message = "RT injected";
String listenerMessage = idTokenCredential.getRoutingContext().get("listener-message");
if (listenerMessage != null) {
message += ("(" + listenerMessage + ")");
}
return message;
} else {
return "no refresh";
}
}

@GET
@Path("refresh/tenant-listener")
public String refreshTenantListener() {
return refresh();
}

@GET
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.quarkus.it.keycloak;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;

import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class SecurityEventListener {

public void event(@Observes SecurityEvent event) {
String tenantId = event.getSecurityIdentity().getAttribute("tenant-id");
boolean blockingApiAvailable = event.getSecurityIdentity()
.getAttribute(AuthenticationRequestContext.class.getName()) != null;

RoutingContext vertxContext = event.getSecurityIdentity().getCredential(IdTokenCredential.class).getRoutingContext();
vertxContext.put("listener-message",
String.format("event:%s,tenantId:%s,blockingApi:%b", event.getEventType().name(), tenantId,
blockingApiAvailable));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ quarkus.oidc.authentication.cookie-domain=localhost
quarkus.oidc.authentication.extra-params.max-age=60
quarkus.oidc.application-type=web-app

# Tenant listener configuration for testing that the login event has been captured
quarkus.oidc.tenant-listener.auth-server-url=${keycloak.url}/realms/quarkus
quarkus.oidc.tenant-listener.client-id=quarkus-app
quarkus.oidc.tenant-listener.credentials.secret=secret
quarkus.oidc.tenant-listener.authentication.cookie-path=/
# Redirect parameters are dropped by redirecting the authenticated user but this final redirect loses the login event message
# on Vertx context; so disabling it for the test endpoint to confirm the login event has been accepted
quarkus.oidc.tenant-listener.authentication.remove-redirect-parameters=false
quarkus.oidc.tenant-listener.application-type=web-app


# Tenant which does not need to restore a request path after redirect, client_secret_post method
quarkus.oidc.tenant-1.auth-server-url=${keycloak.url}/realms/quarkus
Expand Down
Loading

0 comments on commit 4e15e67

Please sign in to comment.