Skip to content

Commit

Permalink
Better support for OIDC without the discovery endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Jul 9, 2020
1 parent b043880 commit ddbc736
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,42 +32,85 @@ public class OidcTenantConfig {
@ConfigItem(defaultValue = "service")
public ApplicationType applicationType;

/**
* The maximum amount of time the adapter will try connecting to the currently unavailable OIDC server for.
* For example, setting it to '20S' will let the adapter keep requesting the connection for up to 20 seconds.
*/
@ConfigItem
public Optional<Duration> connectionDelay = Optional.empty();

/**
* The base URL of the OpenID Connect (OIDC) server, for example, 'https://host:port/auth'.
* OIDC discovery endpoint will be called by appending a '/.well-known/openid-configuration' path segment to this URL.
* OIDC discovery endpoint will be called by default by appending a '.well-known/openid-configuration' path to this URL.
* Note if you work with Keycloak OIDC server, make sure the base URL is in the following format:
* 'https://host:port/auth/realms/{realm}' where '{realm}' has to be replaced by the name of the Keycloak realm.
*/
@ConfigItem
public Optional<String> authServerUrl = Optional.empty();

/**
* Relative path of the RFC7662 introspection service.
* Enables an OIDC discovery.
* If the discovery is disabled then at least the following properties must be configured:
* - `authorization-path`, `token-path` and either `jwks-path` or `introspection-path` for the 'web-app' applications
* - `jwks-path` or `introspection-path` for the 'service' applications
*/
@ConfigItem(defaultValue = "true")
public boolean discoveryEnabled = true;

/**
* Relative path of the OIDC authorization endpoint which authenticates the users.
* This property must be set for the 'web-app' applications if OIDC discovery is disabled.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> authorizationPath = Optional.empty();

/**
* Relative path of the OIDC token endpoint which issues ID, access and refresh tokens.
* This property must be set for the 'web-app' applications if OIDC discovery is disabled.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> tokenPath = Optional.empty();

/**
* Relative path of the OIDC userinfo endpoint.
* This property must only be set for the 'web-app' applications if OIDC discovery is disabled
* and 'authntication.userinfo-required' property is enabled.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> userInfoPath = Optional.empty();

/**
* Relative path of the OIDC RFC7662 introspection endpoint which can introspect both opaque and JWT tokens.
* This property must be set if OIDC discovery is disabled and 1) the opaque bearer access tokens have to be verified
* or 2) JWT tokens have be verified while the cached JWK verification set has no matching JWK and is being refreshed.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> introspectionPath = Optional.empty();

/**
* Relative path of the OIDC service returning a JWK set.
* Relative path of the OIDC JWKS endpoint which returns a JSON Web Key Verification Set.
* Set this property if OIDC discovery is disabled and RP Initiated Logout support is required.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> jwksPath = Optional.empty();

/**
* Relative path of the OIDC end_session_endpoint.
* Set this property if OIDC discovery is disabled and RP Initiated Logout support for the 'web-app' applications is
* required.
* This property will be ignored if the discovery is enabled.
*/
@ConfigItem
public Optional<String> endSessionPath = Optional.empty();

/**
* The maximum amount of time the adapter will try connecting to the currently unavailable OIDC server for.
* For example, setting it to '20S' will let the adapter keep requesting the connection for up to 20 seconds.
*/
@ConfigItem
public Optional<Duration> connectionDelay = Optional.empty();

/**
* Public key for the local JWT token verification.
* OIDC server connection will not be created when this property is set.
*/
@ConfigItem
public Optional<String> publicKey = Optional.empty();
Expand Down Expand Up @@ -200,6 +243,30 @@ public void setAuthServerUrl(String authServerUrl) {
this.authServerUrl = Optional.of(authServerUrl);
}

public Optional<String> getAuthorizationPath() {
return authorizationPath;
}

public void setAuthorizationPath(String authorizationPath) {
this.authorizationPath = Optional.of(authorizationPath);
}

public Optional<String> getTokenPath() {
return tokenPath;
}

public void setTokenPath(String tokenPath) {
this.tokenPath = Optional.of(tokenPath);
}

public Optional<String> getUserInfoPath() {
return userInfoPath;
}

public void setUserInfoPath(String userInfoPath) {
this.userInfoPath = Optional.of(userInfoPath);
}

public Optional<String> getIntrospectionPath() {
return introspectionPath;
}
Expand Down Expand Up @@ -280,6 +347,22 @@ public void setTenantId(String tenantId) {
this.tenantId = Optional.of(tenantId);
}

public boolean isTenantEnabled() {
return tenantEnabled;
}

public void setTenantEnabled(boolean enabled) {
this.tenantEnabled = enabled;
}

public boolean isDiscoveryEnabled() {
return discoveryEnabled;
}

public void setDiscoveryEnabled(boolean enabled) {
this.discoveryEnabled = enabled;
}

public Proxy getProxy() {
return proxy;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

Expand All @@ -19,6 +19,8 @@
import io.quarkus.oidc.OidcTenantConfig.Tls.Verification;
import io.quarkus.runtime.annotations.Recorder;
import io.quarkus.runtime.configuration.ConfigurationException;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.subscription.UniEmitter;
import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
Expand Down Expand Up @@ -109,14 +111,41 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi
authServerUrl = authServerUrl.substring(0, authServerUrl.length() - 1);
}
options.setSite(authServerUrl);
// RFC7662 introspection service address
if (oidcConfig.getIntrospectionPath().isPresent()) {
options.setIntrospectionPath(oidcConfig.getIntrospectionPath().get());
}

// RFC7662 JWKS service address
if (oidcConfig.getJwksPath().isPresent()) {
options.setJwkPath(oidcConfig.getJwksPath().get());
if (!oidcConfig.discoveryEnabled) {
if (ApplicationType.WEB_APP.equals(oidcConfig.applicationType)) {
if (!oidcConfig.authorizationPath.isPresent() || !oidcConfig.tokenPath.isPresent()) {
throw new OIDCException("'web-app' applications must have 'authorization-path' and 'token-path' properties "
+ "set when the discovery is disabled.");
}
// These endpoints can only be used with the code flow
if (oidcConfig.getAuthorizationPath().isPresent()) {
options.setAuthorizationPath(authServerUrl + prependSlash(oidcConfig.getAuthorizationPath().get()));
}

if (oidcConfig.getTokenPath().isPresent()) {
options.setTokenPath(authServerUrl + prependSlash(oidcConfig.getTokenPath().get()));
}

if (oidcConfig.getUserInfoPath().isPresent()) {
options.setUserInfoPath(authServerUrl + prependSlash(oidcConfig.getUserInfoPath().get()));
}
}

// JWK and introspection endpoints have to be set for both 'web-app' and 'service' applications
if (!oidcConfig.jwksPath.isPresent() && !oidcConfig.introspectionPath.isPresent()) {
throw new OIDCException(
"Either 'jwks-path' or 'introspection-path' properties must be set when the discovery is disabled.");
}

if (oidcConfig.getIntrospectionPath().isPresent()) {
options.setIntrospectionPath(authServerUrl + prependSlash(oidcConfig.getIntrospectionPath().get()));
}

if (oidcConfig.getJwksPath().isPresent()) {
options.setJwkPath(authServerUrl + prependSlash(oidcConfig.getJwksPath().get()));
}

}

Credentials creds = oidcConfig.getCredentials();
Expand Down Expand Up @@ -179,19 +208,11 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi
OAuth2Auth auth = null;
for (long i = 0; i < connectionRetryCount; i++) {
try {
CompletableFuture<OAuth2Auth> cf = new CompletableFuture<>();
KeycloakAuth.discover(vertx, options, new Handler<AsyncResult<OAuth2Auth>>() {
@Override
public void handle(AsyncResult<OAuth2Auth> event) {
if (event.failed()) {
cf.completeExceptionally(toOidcException(event.cause()));
} else {
cf.complete(event.result());
}
}
});

auth = cf.join();
if (oidcConfig.discoveryEnabled) {
auth = discoverOidcEndpoints(vertx, options);
} else {
auth = setOidcEndpoints(vertx, options);
}

break;
} catch (Throwable throwable) {
Expand Down Expand Up @@ -227,8 +248,50 @@ public void handle(AsyncResult<OAuth2Auth> event) {
return new TenantConfigContext(auth, oidcConfig);
}

private static String prependSlash(String path) {
return !path.startsWith("/") ? "/" + path : path;
}

private static OAuth2Auth discoverOidcEndpoints(Vertx vertx, OAuth2ClientOptions options) {
return Uni.createFrom().emitter(new Consumer<UniEmitter<? super OAuth2Auth>>() {
public void accept(UniEmitter<? super OAuth2Auth> uniEmitter) {
KeycloakAuth.discover(vertx, options, new Handler<AsyncResult<OAuth2Auth>>() {
@Override
public void handle(AsyncResult<OAuth2Auth> event) {
if (event.failed()) {
uniEmitter.fail(toOidcException(event.cause()));
} else {
uniEmitter.complete(event.result());
}
}
});
}
}).await().indefinitely();
}

private static OAuth2Auth setOidcEndpoints(Vertx vertx, OAuth2ClientOptions options) {
if (options.getJwkPath() != null) {
return Uni.createFrom().emitter(new Consumer<UniEmitter<? super OAuth2Auth>>() {
@SuppressWarnings("deprecation")
@Override
public void accept(UniEmitter<? super OAuth2Auth> uniEmitter) {
OAuth2Auth auth = OAuth2Auth.create(vertx, options);
auth.loadJWK(res -> {
if (res.failed()) {
uniEmitter.fail(toOidcException(res.cause()));
}
uniEmitter.complete(auth);
});
}
}).await().indefinitely();
} else {
return OAuth2Auth.create(vertx, options);
}
}

@SuppressWarnings("deprecation")
private TenantConfigContext createdTenantContextFromPublicKey(OAuth2ClientOptions options, OidcTenantConfig oidcConfig) {
private static TenantConfigContext createdTenantContextFromPublicKey(OAuth2ClientOptions options,
OidcTenantConfig oidcConfig) {
if (oidcConfig.applicationType == ApplicationType.WEB_APP) {
throw new ConfigurationException("'public-key' property can only be used with the 'service' applications");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ public OidcTenantConfig resolve(RoutingContext context) {
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId("tenant-oidc");
String uri = context.request().absoluteURI();
String keycloakUri = path.contains("tenant-opaque")
String authServerUri = path.contains("tenant-opaque")
? uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc")
: uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
config.setAuthServerUrl(keycloakUri);
config.setAuthServerUrl(authServerUri);
config.setClientId("client");
return config;
} else if ("tenant-oidc-no-discovery".equals(tenantId)) {
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId("tenant-oidc-no-discovery");
String uri = context.request().absoluteURI();
String authServerUri = uri.replace("/tenant/tenant-oidc-no-discovery/api/user", "/oidc");
config.setAuthServerUrl(authServerUri);
config.setDiscoveryEnabled(false);
config.setJwksPath("jwks");
config.setClientId("client");
config.getCredentials().setSecret("secret");
return config;
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ public int jwkEndpointCallCount() {
return jwkEndpointCallCount;
}

@POST
@Path("jwk-endpoint-call-count")
public int resetJwkEndpointCallCount() {
jwkEndpointCallCount = 0;
return jwkEndpointCallCount;
}

@POST
@Produces("application/json")
@Path("introspect")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ public void testDefaultTenant() {

@Test
public void testSimpleOidcJwtWithJwkRefresh() {
RestAssured.when().post("/oidc/jwk-endpoint-call-count").then().body(equalTo("0"));
RestAssured.when().get("/oidc/introspection-status").then().body(equalTo("false"));
RestAssured.when().get("/oidc/rotate-status").then().body(equalTo("false"));
// Quarkus OIDC is initialized with JWK set with kid '1' as part of the discovery process
Expand Down Expand Up @@ -191,6 +192,23 @@ public Boolean call() throws Exception {
RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("2"));
}

@Test
public void testSimpleOidcNoDiscovery() throws Exception {
RestAssured.when().post("/oidc/jwk-endpoint-call-count").then().body(equalTo("0"));
RestAssured.when().get("/oidc/introspection-status").then().body(equalTo("false"));
RestAssured.when().get("/oidc/rotate-status").then().body(equalTo("false"));

Thread.sleep(3000);

// Quarkus OIDC is initialized with JWK set with kid '1' as part of the initialization process
RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("1"))
.when().get("/tenant/tenant-oidc-no-discovery/api/user")
.then()
.statusCode(200)
.body(equalTo("tenant-oidc-no-discovery:alice"));
RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("1"));
}

private String getAccessToken(String userName, String clientId) {
return RestAssured
.given()
Expand Down

0 comments on commit ddbc736

Please sign in to comment.