diff --git a/.gitignore b/.gitignore index dbd874932..bdc3d43c8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,4 @@ src/main/java/io/vertx/java/**/*.java src/main/groovy/io/vertx/groovy/**/*.groovy *.swp node_modules - +.vscode diff --git a/vertx-auth-oauth2/src/main/asciidoc/index.adoc b/vertx-auth-oauth2/src/main/asciidoc/index.adoc index 7fb5d9615..a8e84b674 100644 --- a/vertx-auth-oauth2/src/main/asciidoc/index.adoc +++ b/vertx-auth-oauth2/src/main/asciidoc/index.adoc @@ -400,3 +400,34 @@ In such event the server will start emitting tokens with a different kid than th ---- A special note on this is that if a user will send many requests with a missing key, your handler should throttle the calls to refresh the new key set, or you might end up DDoS your IdP server. + +== Dynamic Client Registration (RFC 7591) +https://datatracker.ietf.org/doc/html/rfc7591[RFC 7591] was written to standardize the process of client registration in OAuth 2.0 and OpenID Connect ecosystems to address the limitations of manual, inconsistent, and non-scalable client registration methods that were common before its publication. +The current implementation has been tested with Keycloak. If you want to enable dynamic client registration, here are the steps to follow: +Prerequisite: Create an initial access token from the Keycloak server. You can create an initial access token using either the https://www.keycloak.org/securing-apps/client-registration#_initial_access_token[admin] console or programmatically. + +To create a client in Keycloak using the initial access token and a client ID of your choice, follow the example below. +[source,$lang] +---- +{@link examples.AuthOAuth2Examples#example23} +---- +To read a client that you have already presented in Keycloak, follow the following example. +[source,$lang] +---- +{@link examples.AuthOAuth2Examples#example24} +---- +To remove a client that is already present in Keycloak, follow the following example. +[source,$lang] +---- +{@link examples.AuthOAuth2Examples#example27} +---- +To create an initial access token programmatically, refer to the following example. +[source,$lang] +---- +{@link examples.AuthOAuth2Examples#example28} +---- +To create an admin access token programmatically, refer to the following example. +[source,$lang] +---- +{@link examples.AuthOAuth2Examples#example29} +---- diff --git a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCROptionsConverter.java b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCROptionsConverter.java new file mode 100644 index 000000000..a073c9658 --- /dev/null +++ b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCROptionsConverter.java @@ -0,0 +1,57 @@ +package io.vertx.ext.auth.oauth2; + +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; + +/** + * Converter and mapper for {@link io.vertx.ext.auth.oauth2.DCROptions}. + * NOTE: This class has been automatically generated from the {@link io.vertx.ext.auth.oauth2.DCROptions} original class using Vert.x codegen. + */ +public class DCROptionsConverter { + + static void fromJson(Iterable> json, DCROptions obj) { + for (java.util.Map.Entry member : json) { + switch (member.getKey()) { + case "httpClientOptions": + if (member.getValue() instanceof JsonObject) { + obj.setHttpClientOptions(new io.vertx.core.http.HttpClientOptions((io.vertx.core.json.JsonObject)member.getValue())); + } + break; + case "initialAccessToken": + if (member.getValue() instanceof String) { + obj.setInitialAccessToken((String)member.getValue()); + } + break; + case "site": + if (member.getValue() instanceof String) { + obj.setSite((String)member.getValue()); + } + break; + case "tenant": + if (member.getValue() instanceof String) { + obj.setTenant((String)member.getValue()); + } + break; + } + } + } + + static void toJson(DCROptions obj, JsonObject json) { + toJson(obj, json.getMap()); + } + + static void toJson(DCROptions obj, java.util.Map json) { + if (obj.getHttpClientOptions() != null) { + json.put("httpClientOptions", obj.getHttpClientOptions().toJson()); + } + if (obj.getInitialAccessToken() != null) { + json.put("initialAccessToken", obj.getInitialAccessToken()); + } + if (obj.getSite() != null) { + json.put("site", obj.getSite()); + } + if (obj.getTenant() != null) { + json.put("tenant", obj.getTenant()); + } + } +} diff --git a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRRequestConverter.java b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRRequestConverter.java new file mode 100644 index 000000000..b83547e3f --- /dev/null +++ b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRRequestConverter.java @@ -0,0 +1,41 @@ +package io.vertx.ext.auth.oauth2; + +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; + +/** + * Converter and mapper for {@link io.vertx.ext.auth.oauth2.DCRRequest}. + * NOTE: This class has been automatically generated from the {@link io.vertx.ext.auth.oauth2.DCRRequest} original class using Vert.x codegen. + */ +public class DCRRequestConverter { + + static void fromJson(Iterable> json, DCRRequest obj) { + for (java.util.Map.Entry member : json) { + switch (member.getKey()) { + case "clientId": + if (member.getValue() instanceof String) { + obj.setClientId((String)member.getValue()); + } + break; + case "registrationAccessToken": + if (member.getValue() instanceof String) { + obj.setRegistrationAccessToken((String)member.getValue()); + } + break; + } + } + } + + static void toJson(DCRRequest obj, JsonObject json) { + toJson(obj, json.getMap()); + } + + static void toJson(DCRRequest obj, java.util.Map json) { + if (obj.getClientId() != null) { + json.put("clientId", obj.getClientId()); + } + if (obj.getRegistrationAccessToken() != null) { + json.put("registrationAccessToken", obj.getRegistrationAccessToken()); + } + } +} diff --git a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRResponseConverter.java b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRResponseConverter.java new file mode 100644 index 000000000..75f7d5d6e --- /dev/null +++ b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/DCRResponseConverter.java @@ -0,0 +1,71 @@ +package io.vertx.ext.auth.oauth2; + +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; + +/** + * Converter and mapper for {@link io.vertx.ext.auth.oauth2.DCRResponse}. + * NOTE: This class has been automatically generated from the {@link io.vertx.ext.auth.oauth2.DCRResponse} original class using Vert.x codegen. + */ +public class DCRResponseConverter { + + static void fromJson(Iterable> json, DCRResponse obj) { + for (java.util.Map.Entry member : json) { + switch (member.getKey()) { + case "id": + if (member.getValue() instanceof String) { + obj.setId((String)member.getValue()); + } + break; + case "clientId": + if (member.getValue() instanceof String) { + obj.setClientId((String)member.getValue()); + } + break; + case "enabled": + if (member.getValue() instanceof Boolean) { + obj.setEnabled((Boolean)member.getValue()); + } + break; + case "clientAuthenticatorType": + if (member.getValue() instanceof String) { + obj.setClientAuthenticatorType((String)member.getValue()); + } + break; + case "secret": + if (member.getValue() instanceof String) { + obj.setSecret((String)member.getValue()); + } + break; + case "registrationAccessToken": + if (member.getValue() instanceof String) { + obj.setRegistrationAccessToken((String)member.getValue()); + } + break; + } + } + } + + static void toJson(DCRResponse obj, JsonObject json) { + toJson(obj, json.getMap()); + } + + static void toJson(DCRResponse obj, java.util.Map json) { + if (obj.getId() != null) { + json.put("id", obj.getId()); + } + if (obj.getClientId() != null) { + json.put("clientId", obj.getClientId()); + } + json.put("enabled", obj.isEnabled()); + if (obj.getClientAuthenticatorType() != null) { + json.put("clientAuthenticatorType", obj.getClientAuthenticatorType()); + } + if (obj.getSecret() != null) { + json.put("secret", obj.getSecret()); + } + if (obj.getRegistrationAccessToken() != null) { + json.put("registrationAccessToken", obj.getRegistrationAccessToken()); + } + } +} diff --git a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/OAuth2OptionsConverter.java b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/OAuth2OptionsConverter.java index 1d60ba305..65c788a09 100644 --- a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/OAuth2OptionsConverter.java +++ b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/OAuth2OptionsConverter.java @@ -2,8 +2,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonArray; -import java.time.Instant; -import java.time.format.DateTimeFormatter; /** * Converter and mapper for {@link io.vertx.ext.auth.oauth2.OAuth2Options}. diff --git a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/Oauth2CredentialsConverter.java b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/Oauth2CredentialsConverter.java index 90a7362bd..13a7018e7 100644 --- a/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/Oauth2CredentialsConverter.java +++ b/vertx-auth-oauth2/src/main/generated/io/vertx/ext/auth/oauth2/Oauth2CredentialsConverter.java @@ -2,8 +2,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonArray; -import java.time.Instant; -import java.time.format.DateTimeFormatter; /** * Converter and mapper for {@link io.vertx.ext.auth.oauth2.Oauth2Credentials}. diff --git a/vertx-auth-oauth2/src/main/java/examples/AuthOAuth2Examples.java b/vertx-auth-oauth2/src/main/java/examples/AuthOAuth2Examples.java index 86c555bcc..e644e58e6 100644 --- a/vertx-auth-oauth2/src/main/java/examples/AuthOAuth2Examples.java +++ b/vertx-auth-oauth2/src/main/java/examples/AuthOAuth2Examples.java @@ -16,7 +16,11 @@ package examples; +import io.vertx.core.Future; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerResponse; import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.User; @@ -26,8 +30,10 @@ import io.vertx.ext.auth.authorization.AuthorizationProvider; import io.vertx.ext.auth.authorization.PermissionBasedAuthorization; import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import io.vertx.ext.auth.impl.http.SimpleHttpClient; import io.vertx.ext.auth.oauth2.*; import io.vertx.ext.auth.oauth2.authorization.KeycloakAuthorization; +import io.vertx.ext.auth.oauth2.dcr.KeycloakClientRegistration; import io.vertx.ext.auth.oauth2.providers.*; /** @@ -42,8 +48,7 @@ public void example1(Vertx vertx) { .setClientSecret("YOUR_CLIENT_SECRET") .setSite("https://github.com/login") .setTokenPath("/oauth/access_token") - .setAuthorizationPath("/oauth/authorize") - ); + .setAuthorizationPath("/oauth/authorize")); // when there is a need to access a protected resource // or call a protected method, call the authZ url for @@ -82,7 +87,6 @@ public void example2(Vertx vertx, HttpServerResponse response) { .setClientSecret("") .setSite("https://api.oauth.com"); - // Initialize the OAuth2 Library OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials); @@ -145,7 +149,6 @@ public void example4(Vertx vertx) { .setClientSecret("") .setSite("https://api.oauth.com"); - // Initialize the OAuth2 Library OAuth2Auth oauth2 = OAuth2Auth.create(vertx, credentials); @@ -212,10 +215,9 @@ public void example13(Vertx vertx) { authz.getAuthorizations(user) .onSuccess(v -> { - if ( - RoleBasedAuthorization.create("manage-account") - .setResource("account") - .match(user)) { + if (RoleBasedAuthorization.create("manage-account") + .setResource("account") + .match(user)) { // this user is authorized to manage its account } }); @@ -231,7 +233,6 @@ public void example14(User user) { String username = user.principal().getString("preferred_username"); } - public void example15(OAuth2Auth oauth2, User user) { // OAuth2Auth level oauth2.authenticate(new TokenCredentials("opaque string")) @@ -254,7 +255,6 @@ public void example16(OAuth2Auth oauth2) { }); } - public void example17(User user) { // in this case it is assumed that the role is the current application if (PermissionBasedAuthorization.create("print").match(user)) { @@ -265,10 +265,9 @@ public void example17(User user) { public void example18(User user) { // the resource is "realm" // the authority is "add-user" - if ( - PermissionBasedAuthorization.create("add-user") - .setResource("realm") - .match(user)) { + if (PermissionBasedAuthorization.create("add-user") + .setResource("realm") + .match(user)) { // Yes the user can add users to the application } } @@ -276,10 +275,9 @@ public void example18(User user) { public void example19(User user) { // the role is "finance" // the authority is "year-report" - if ( - PermissionBasedAuthorization.create("year-report") - .setResource("finance") - .match(user)) { + if (PermissionBasedAuthorization.create("year-report") + .setResource("finance") + .match(user)) { // Yes the user can access the year report from the finance department } } @@ -443,4 +441,81 @@ public void example22(OAuth2Auth oauth2) { } }); } + + // create a dynamic client in keycloak 25.0.0 + public void example23(Vertx vertx) { + JsonObject options = new JsonObject().put("site", "https://server:port") + .put("tenant", "master") + .put("initialAccessToken", "initial-access-token"); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create(vertx, + new DCROptions(options)); + keycloakClientRegistration.create("junit-test-client").onSuccess(v -> { + // ... + }); + } + + // get a dynamic client from keycloak 25.0.0 + public void example24(Vertx vertx) { + JsonObject options = new JsonObject().put("site", "https://server:port") + .put("tenant", "master") + .put("initialAccessToken", "initial-access-token"); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create(vertx, + new DCROptions(options)); + // registrationAccessToken is unique for Keycloak implementation + JsonObject requJsonObject = new JsonObject().put("registrationAccessToken", + "registration-access-token") + .put("clientId", "junit-test-client"); + keycloakClientRegistration.get(new DCRRequest(requJsonObject)).onSuccess(v -> { + // ... + }); + } + + // delete a dynamic client from keycloak 25.0.0 + public void example27(Vertx vertx) { + JsonObject options = new JsonObject().put("site", "https://server:port") + .put("tenant", "master") + .put("initialAccessToken", "initial-access-token"); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create(vertx, + new DCROptions(options)); + // registrationAccessToken is unique for Keycloak implementation + JsonObject requJsonObject = new JsonObject().put("registrationAccessToken", + "registration-access-token") + .put("clientId", "junit-test-client"); + keycloakClientRegistration.delete(new DCRRequest(requJsonObject)).onSuccess(v -> { + // ... + }); + } + + // create initial access token in keycloak 25.0.0 + public void example28(Vertx vertx) { + JsonObject header = new JsonObject().put("Authorization", + String.format("Bearer %s", "admin-access-token")) + .put("Content-Type", "application/json"); + JsonObject payload = new JsonObject() + .put("expiration", 180) + .put("count", 1); + new SimpleHttpClient(vertx, "https://server:port", new HttpClientOptions()) + .fetch(HttpMethod.POST, + "https://server:port/admin/realms/master/clients-initial-access", + header, payload.toBuffer()) + .onSuccess(v -> { + // get the initial access token from v.jsonObject().getString("token") + // ... + }); + } + + // create a admin token to create initial access token in keycloak 25.0.0 + public void example29(Vertx vertx) { + JsonObject header = new JsonObject().put("Content-Type", "application/x-www-form-urlencoded"); + Buffer body = Buffer.buffer( + "grant_type=password&client_id=admin-cli&username=admin&password=secret"); + new SimpleHttpClient(vertx, "https://server:port", new HttpClientOptions()) + .fetch(HttpMethod.POST, + "https://server:port/realms/master/protocol/openid-connect/token", + header, body) + .onSuccess(v -> { + // get the admin access token from v.jsonObject().getString("access_token") + // ... + }); + } } diff --git a/vertx-auth-oauth2/src/main/java/examples/package-info.java b/vertx-auth-oauth2/src/main/java/examples/package-info.java index 1ffec9a73..bd1756207 100644 --- a/vertx-auth-oauth2/src/main/java/examples/package-info.java +++ b/vertx-auth-oauth2/src/main/java/examples/package-info.java @@ -1,4 +1,4 @@ @Source package examples; -import io.vertx.docgen.Source; \ No newline at end of file +import io.vertx.docgen.Source; diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/ClientRegistrationProvider.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/ClientRegistrationProvider.java new file mode 100644 index 000000000..2c255f45f --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/ClientRegistrationProvider.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Future; + +/** + * + */ +@VertxGen +public interface ClientRegistrationProvider { + Future create(String clientId); + + Future get(DCRRequest dcrRequest); + + Future delete(DCRRequest dcrRequest); + +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCROptions.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCROptions.java new file mode 100644 index 000000000..46debc1ab --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCROptions.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.json.JsonObject; + +@DataObject +@JsonGen(publicConverter = false) +public final class DCROptions { + + /** + * The base url of the OIDC provider like Keycloak. + */ + private String site; + + /** + * Name of the tenant if any. Keycloak call this realm. + */ + private String tenant; + + /** + * Initial access token to authenticate with the OIDC provider. + */ + private String initialAccessToken; + + private HttpClientOptions httpClientOptions = new HttpClientOptions(); + + public DCROptions(JsonObject json) { + DCROptionsConverter.fromJson(json, this); + } + + public JsonObject toJson() { + final JsonObject json = new JsonObject(); + DCROptionsConverter.toJson(this, json); + return json; + } + + public HttpClientOptions getHttpClientOptions() { + return httpClientOptions; + } + + public void setHttpClientOptions(HttpClientOptions httpClientOptions) { + this.httpClientOptions = httpClientOptions; + } + + public String getInitialAccessToken() { + return initialAccessToken; + } + + public void setInitialAccessToken(String initialAccessToken) { + this.initialAccessToken = initialAccessToken; + } + + public String getSite() { + return site; + } + + public void setSite(String site) { + this.site = site; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(String tenant) { + this.tenant = tenant; + } + + public String resourceUri() { + return String.format("%s/realms/%s/clients-registrations/default", site, tenant); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRRequest.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRRequest.java new file mode 100644 index 000000000..718ff4441 --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRRequest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.json.JsonObject; + +@DataObject +@JsonGen(publicConverter = false) +public class DCRRequest { + + /** + * The client id you want to give it to the client you want to create. + */ + private String clientId; + + /** + * The token you received when you registered your client with Keycloak. + */ + private String registrationAccessToken; + + public DCRRequest(JsonObject json) { + DCRRequestConverter.fromJson(json, this); + } + + public JsonObject toJson() { + final JsonObject json = new JsonObject(); + DCRRequestConverter.toJson(this, json); + return json; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRegistrationAccessToken() { + return registrationAccessToken; + } + + public void setRegistrationAccessToken(String registrationAccessToken) { + this.registrationAccessToken = registrationAccessToken; + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRResponse.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRResponse.java new file mode 100644 index 000000000..01bfaea59 --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/DCRResponse.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.json.JsonObject; + +@DataObject +@JsonGen(publicConverter = false) +public class DCRResponse { + + /** + * A system generated unique identifier. + */ + private String id; + + /** + * User given client identifier. + */ + private String clientId; + /** + * Whether the client is currently enabled or not. + */ + private boolean enabled; + + /** + * Client authenticator type, by default it is client-secret. + */ + private String clientAuthenticatorType; + + /** + * Client secret for client_secret_post or client_secret_basic. + */ + private String secret; + + /** + * RegistrationAccessToken is used for subsequent communication with Keycloak to + * GET or DELETE the client. + */ + private String registrationAccessToken; + + public DCRResponse(JsonObject json) { + DCRResponseConverter.fromJson(json, this); + } + + public JsonObject toJson() { + final JsonObject json = new JsonObject(); + DCRResponseConverter.toJson(this, json); + return json; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean isEnabled) { + this.enabled = isEnabled; + } + + public String getClientAuthenticatorType() { + return clientAuthenticatorType; + } + + public void setClientAuthenticatorType(String clientAuthenticatorType) { + this.clientAuthenticatorType = clientAuthenticatorType; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getRegistrationAccessToken() { + return registrationAccessToken; + } + + public void setRegistrationAccessToken(String registrationAccessToken) { + this.registrationAccessToken = registrationAccessToken; + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/OAuth2Auth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/OAuth2Auth.java index 1fe0e6500..96ac22c6c 100644 --- a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/OAuth2Auth.java +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/OAuth2Auth.java @@ -18,7 +18,6 @@ import io.vertx.codegen.annotations.Fluent; import io.vertx.codegen.annotations.VertxGen; -import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/KeycloakClientRegistration.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/KeycloakClientRegistration.java new file mode 100644 index 000000000..3b4fa0e8e --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/KeycloakClientRegistration.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2.dcr; + +import io.vertx.core.Vertx; +import io.vertx.ext.auth.oauth2.ClientRegistrationProvider; +import io.vertx.ext.auth.oauth2.DCROptions; +import io.vertx.ext.auth.oauth2.dcr.impl.KeycloakClientRegistrationImpl; + +public interface KeycloakClientRegistration extends ClientRegistrationProvider { + + static KeycloakClientRegistration create(Vertx vertx, DCROptions dcrOptions) { + return new KeycloakClientRegistrationImpl(vertx, dcrOptions); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/impl/KeycloakClientRegistrationImpl.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/impl/KeycloakClientRegistrationImpl.java new file mode 100644 index 000000000..f32d5b4c5 --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/dcr/impl/KeycloakClientRegistrationImpl.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.ext.auth.oauth2.dcr.impl; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.impl.http.SimpleHttpClient; +import io.vertx.ext.auth.impl.http.SimpleHttpResponse; +import io.vertx.ext.auth.oauth2.DCROptions; +import io.vertx.ext.auth.oauth2.DCRRequest; +import io.vertx.ext.auth.oauth2.DCRResponse; +import io.vertx.ext.auth.oauth2.dcr.KeycloakClientRegistration; +import java.util.Objects; + +public final class KeycloakClientRegistrationImpl implements KeycloakClientRegistration { + + private final SimpleHttpClient simpleHttpClient; + + private final DCROptions dcrOptions; + + public KeycloakClientRegistrationImpl(Vertx vertx, DCROptions dcrOptions) { + Objects.requireNonNull(dcrOptions.getInitialAccessToken(), "initialAccessToken cannot be null"); + Objects.requireNonNull(dcrOptions.getSite(), "site cannot be null"); + Objects.requireNonNull(dcrOptions.getTenant(), "tenant cannot be null"); + this.dcrOptions = dcrOptions; + this.simpleHttpClient = new SimpleHttpClient(vertx, "dcr-client", dcrOptions.getHttpClientOptions()); + } + + @Override + public Future create(String clientId) { + JsonObject initialAccessToken = JsonObject.of("Authorization", + String.format("Bearer %s", dcrOptions.getInitialAccessToken())); + JsonObject payload = JsonObject.of("clientId", clientId); + return simpleHttpClient.fetch(HttpMethod.POST, dcrOptions.resourceUri(), initialAccessToken, + payload.toBuffer()).compose(response -> constructResponse(response, 201)); + } + + @Override + public Future get(DCRRequest dcrRequest) { + Objects.requireNonNull(dcrRequest.getClientId(), "clientId cannot be null."); + Objects.requireNonNull(dcrRequest.getRegistrationAccessToken(), "registrationAccessToken cannot be null."); + JsonObject registrationToken = JsonObject.of("Authorization", + String.format("Bearer %s", dcrRequest.getRegistrationAccessToken())); + return simpleHttpClient.fetch(HttpMethod.GET, String.format("%s/%s", dcrOptions.resourceUri(), + dcrRequest.getClientId()), registrationToken, null) + .compose(response -> constructResponse(response, 200)); + } + + @Override + public Future delete(DCRRequest dcrRequest) { + Objects.requireNonNull(dcrRequest.getClientId(), "clientId cannot be null."); + Objects.requireNonNull(dcrRequest.getRegistrationAccessToken(), "registrationAccessToken cannot be null."); + JsonObject registrationToken = JsonObject.of("Authorization", + String.format("Bearer %s", dcrRequest.getRegistrationAccessToken())); + return simpleHttpClient.fetch(HttpMethod.DELETE, String.format("%s/%s", dcrOptions.resourceUri(), + dcrRequest.getClientId()), registrationToken, null) + .compose(response -> { + if (response.statusCode() != 204) { + return Future.failedFuture("Bad Response [" + response.statusCode() + "] " + response.body()); + } + return Future.succeededFuture(); + }); + } + + private Future constructResponse(SimpleHttpResponse response, int expectedStatusCode) { + if (response.statusCode() != expectedStatusCode) { + return Future.failedFuture("Bad Response [" + response.statusCode() + "] " + response.body()); + } + if (!response.is("application/json")) { + return Future.failedFuture("Cannot handle Content-Type: " + response.headers().get("Content-Type")); + } + final JsonObject json = response.jsonObject(); + if (json == null) { + return Future.failedFuture("Cannot handle null JSON"); + } + return Future.succeededFuture(new DCRResponse(json)); + } +} diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRKeycloak25_0_0_IT.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRKeycloak25_0_0_IT.java new file mode 100644 index 000000000..9304de908 --- /dev/null +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRKeycloak25_0_0_IT.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Sanju Thomas + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.impl.http.SimpleHttpClient; +import io.vertx.ext.auth.oauth2.DCROptions; +import io.vertx.ext.auth.oauth2.DCRRequest; +import io.vertx.ext.auth.oauth2.DCRResponse; +import io.vertx.ext.auth.oauth2.dcr.KeycloakClientRegistration; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.RunTestOnContext; +import io.vertx.ext.unit.junit.VertxUnitRunnerWithParametersFactory; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.containers.wait.strategy.Wait; + +public class DCRKeycloak25_0_0_IT { + + private static final String REALM = "master"; + @Rule + public final RunTestOnContext rule = new RunTestOnContext(); + + private static GenericContainer keycloak; + + @BeforeClass + public static void setupDocker() { + keycloak = new GenericContainer<>(DockerImageName.parse("quay.io/keycloak/keycloak:25.0.0")) + .withExposedPorts(8080) + .withEnv("KEYCLOAK_ADMIN", "admin") + .withEnv("KEYCLOAK_ADMIN_PASSWORD", "secret") + .withCommand("start-dev") + .waitingFor( + Wait.forHttp(String.format( + "/realms/%s/.well-known/openid-configuration", REALM)) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(90))); + } + + @Before + public void setup() { + keycloak.start(); + } + + @After + public void tearDown() { + keycloak.stop(); + } + + @Test + public void testCreateDynamicClient() throws Exception { + String baseUrl = String.format("http://%s:%s", keycloak.getHost(), + keycloak.getMappedPort(8080)); + String initialAccessToken = createInitialAccessToken(baseUrl, + getAdminAccessToken(baseUrl).await(10, TimeUnit.SECONDS)) + .await(10, TimeUnit.SECONDS); + JsonObject options = new JsonObject().put("site", baseUrl).put("tenant", REALM).put( + "initialAccessToken", + initialAccessToken); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create( + rule.vertx(), + new DCROptions(options)); + Future dcrResponse = keycloakClientRegistration.create("junit-test-client"); + DCRResponse client = dcrResponse.await(10, TimeUnit.SECONDS); + assertNotNull(client.getId()); + assertEquals("junit-test-client", client.getClientId()); + assertEquals("client-secret", client.getClientAuthenticatorType()); + assertNotNull(client.getRegistrationAccessToken()); + assertNotNull(client.getSecret()); + } + + @Test + public void testGetDynamicClient() throws Exception { + String baseUrl = String.format("http://%s:%s", keycloak.getHost(), + keycloak.getMappedPort(8080)); + String initialAccessToken = createInitialAccessToken(baseUrl, + getAdminAccessToken(baseUrl).await(10, TimeUnit.SECONDS)) + .await(10, TimeUnit.SECONDS); + JsonObject options = new JsonObject().put("site", baseUrl).put("tenant", REALM).put( + "initialAccessToken", + initialAccessToken); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create( + rule.vertx(), + new DCROptions(options)); + Future dcrResponse = keycloakClientRegistration.create("junit-test-client"); + DCRResponse client = dcrResponse.await(10, TimeUnit.SECONDS); + assertNotNull(client.getId()); + assertEquals("junit-test-client", client.getClientId()); + JsonObject requJsonObject = new JsonObject() + .put("registrationAccessToken", client.getRegistrationAccessToken()) + .put("clientId", "junit-test-client"); + DCRRequest dcrRequest = new DCRRequest(requJsonObject); + DCRResponse getResopnse = keycloakClientRegistration.get(dcrRequest) + .await(10, TimeUnit.SECONDS); + assertEquals("junit-test-client", getResopnse.getClientId()); + assertEquals(client.getRegistrationAccessToken(), getResopnse.getRegistrationAccessToken()); + } + + @Test + public void testDleteDynamicClient() throws Exception { + String baseUrl = String.format("http://%s:%s", keycloak.getHost(), + keycloak.getMappedPort(8080)); + String initialAccessToken = createInitialAccessToken(baseUrl, + getAdminAccessToken(baseUrl).await(10, TimeUnit.SECONDS)) + .await(10, TimeUnit.SECONDS); + JsonObject options = new JsonObject().put("site", baseUrl).put("tenant", REALM).put( + "initialAccessToken", + initialAccessToken); + KeycloakClientRegistration keycloakClientRegistration = KeycloakClientRegistration.create( + rule.vertx(), + new DCROptions(options)); + Future dcrResponse = keycloakClientRegistration.create("junit-test-client"); + DCRResponse client = dcrResponse.await(10, TimeUnit.SECONDS); + assertNotNull(client.getId()); + assertEquals("junit-test-client", client.getClientId()); + JsonObject requJsonObject = new JsonObject() + .put("registrationAccessToken", client.getRegistrationAccessToken()) + .put("clientId", "junit-test-client"); + DCRRequest dcrRequest = new DCRRequest(requJsonObject); + DCRResponse getResopnse = keycloakClientRegistration.get(dcrRequest) + .await(10, TimeUnit.SECONDS); + assertEquals("junit-test-client", getResopnse.getClientId()); + assertEquals(client.getRegistrationAccessToken(), getResopnse.getRegistrationAccessToken()); + keycloakClientRegistration.delete(dcrRequest).await(10, TimeUnit.SECONDS); + keycloakClientRegistration.get(dcrRequest).onFailure(load -> { + assertEquals( + "Unauthorized: {\"error\":\"invalid_token\",\"error_description\":\"Not authorized to view client. Not valid token or client credentials provided.\"}", + load.getMessage()); + }); + } + + private Future getAdminAccessToken(String baseUrl) throws Exception { + SimpleHttpClient simpleHttpClient = new SimpleHttpClient(rule.vertx(), baseUrl, + new HttpClientOptions()); + JsonObject header = new JsonObject().put("Content-Type", "application/x-www-form-urlencoded"); + Buffer body = Buffer.buffer( + "grant_type=password&client_id=admin-cli&username=admin&password=secret"); + return simpleHttpClient + .fetch(HttpMethod.POST, + String.format("%s/realms/%s/protocol/openid-connect/token", baseUrl, + REALM), + header, + body) + .compose(response -> Future + .succeededFuture(response.jsonObject().getString("access_token"))); + } + + private Future createInitialAccessToken(String baseUrl, String adminBearer) + throws Exception { + CompletableFuture future = new CompletableFuture<>(); + JsonObject header = new JsonObject().put("Authorization", + String.format("Bearer %s", adminBearer)) + .put("Content-Type", "application/json"); + JsonObject payload = new JsonObject() + .put("expiration", 180) + .put("count", 1); + SimpleHttpClient simpleHttpClient = new SimpleHttpClient(rule.vertx(), baseUrl, + new HttpClientOptions()); + return simpleHttpClient + .fetch(HttpMethod.POST, + String.format("%s/admin/realms/%s/clients-initial-access", baseUrl, + REALM), + header, payload.toBuffer()) + .compose(response -> Future.succeededFuture(response.jsonObject().getString("token"))); + } +} diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCROptionsTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCROptionsTest.java new file mode 100644 index 000000000..3c4f3c752 --- /dev/null +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCROptionsTest.java @@ -0,0 +1,47 @@ +package io.vertx.tests; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; +import io.vertx.core.json.JsonObject; + +import io.vertx.ext.auth.oauth2.DCROptions; + +public class DCROptionsTest { + + private static final String DCR_OPTION_JSO_STRING = "{\n" + + " \"site\": \"https://auth.example.com\",\n" + + " \"tenant\": \"master\",\n" + + " \"initialAccessToken\": \"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIn0\",\n" + + " \"httpClientOptions\": {\n" + + " \"defaultHost\": \"auth.example.com\"\n" + + " }\n" + + "}\n" + + ""; + + private DCROptions dcrOptions; + + @Before + public void setup() { + dcrOptions = new DCROptions(new JsonObject(DCR_OPTION_JSO_STRING)); + } + + @Test + public void testCreateFromJson() { + assertEquals("https://auth.example.com", dcrOptions.getSite()); + assertEquals("master", dcrOptions.getTenant()); + assertEquals("eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIn0", dcrOptions.getInitialAccessToken()); + assertEquals("auth.example.com", dcrOptions.getHttpClientOptions().getDefaultHost()); + } + + @Test + public void testSerializationToJson() { + io.vertx.core.json.JsonObject json = dcrOptions.toJson(); + assertEquals("https://auth.example.com", json.getString("site")); + assertEquals("master", json.getString("tenant")); + assertEquals("eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIn0", json.getString("initialAccessToken")); + assertEquals("auth.example.com", + json.getJsonObject("httpClientOptions").getString("defaultHost")); + } +} diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRRequestTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRRequestTest.java new file mode 100644 index 000000000..21689f193 --- /dev/null +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRRequestTest.java @@ -0,0 +1,35 @@ +package io.vertx.tests; + +import org.junit.Before; +import org.junit.Test; +import io.vertx.core.json.JsonObject; + +import io.vertx.ext.auth.oauth2.DCRRequest; + +public class DCRRequestTest { + + private static final String DCR_REQUEST_STRING = "{\n" + + " \"clientId\": \"my-client-id\",\n" + + " \"registrationAccessToken\": \"my-registration-access-token\"\n" + + "}"; + + private DCRRequest dcrRequest; + + @Before + public void setup() { + this.dcrRequest = new DCRRequest(new JsonObject(DCR_REQUEST_STRING)); + } + + @Test + public void testCreateFromJson() { + assert "my-client-id".equals(dcrRequest.getClientId()); + assert "my-registration-access-token".equals(dcrRequest.getRegistrationAccessToken()); + } + + @Test + public void testToJson() { + io.vertx.core.json.JsonObject json = dcrRequest.toJson(); + assert "my-client-id".equals(json.getString("clientId")); + assert "my-registration-access-token".equals(json.getString("registrationAccessToken")); + } +} diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRResponseTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRResponseTest.java new file mode 100644 index 000000000..065998d1b --- /dev/null +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/DCRResponseTest.java @@ -0,0 +1,47 @@ +package io.vertx.tests; + +import org.junit.Before; +import org.junit.Test; + +import io.vertx.core.json.JsonObject; + +import io.vertx.ext.auth.oauth2.DCRResponse; + +public class DCRResponseTest { + + private static final String DCR_RESPPONSE_STRING = "{\n" + + " \"id\": \"12345\",\n" + + " \"clientId\": \"my-client-id\",\n" + + " \"enabled\": true,\n" + + " \"clientAuthenticatorType\": \"client-secret\",\n" + + " \"secret\": \"my-secret\",\n" + + " \"registrationAccessToken\": \"my-registration-access-token\"\n" + + "}"; + private DCRResponse dcrResponse; + + @Before + public void setup() { + this.dcrResponse = new DCRResponse(new JsonObject(DCR_RESPPONSE_STRING)); + } + + @Test + public void testCreateFromJson() { + assert "12345".equals(dcrResponse.getId()); + assert "my-client-id".equals(dcrResponse.getClientId()); + assert dcrResponse.isEnabled(); + assert "client-secret".equals(dcrResponse.getClientAuthenticatorType()); + assert "my-secret".equals(dcrResponse.getSecret()); + assert "my-registration-access-token".equals(dcrResponse.getRegistrationAccessToken()); + } + + @Test + public void testSerializationToJson() { + JsonObject json = dcrResponse.toJson(); + assert "12345".equals(json.getString("id")); + assert "my-client-id".equals(json.getString("clientId")); + assert json.getBoolean("enabled"); + assert "client-secret".equals(json.getString("clientAuthenticatorType")); + assert "my-secret".equals(json.getString("secret")); + assert "my-registration-access-token".equals(json.getString("registrationAccessToken")); + } +}