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

Better support for OIDC without the discovery endpoint #10613

Merged
merged 1 commit into from
Jul 10, 2020
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 @@ -32,42 +32,87 @@ 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 OIDC discovery.
* If the discovery is disabled then the following properties must be configured:
* - 'authorization-path' and 'token-path' for the 'web-app' applications
* - 'jwks-path' or 'introspection-path' for both the 'web-app' and 'service' applications
* <p>
* 'web-app' applications may also have 'user-info-path' and 'end-session-path' properties configured.
*/
@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 'authentication.user-info-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 to be verified while the cached JWK verification set with no matching JWK 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.
* This property should be set if OIDC discovery is disabled and the local JWT verification 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.
* This property must be set 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 +245,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 +349,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,21 @@ public Boolean call() throws Exception {
RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("2"));
}

@Test
public void testSimpleOidcNoDiscovery() {
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 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