diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java index b72cdbb0..7381d4d9 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java @@ -27,9 +27,11 @@ public static Builder error(String type, int status) { } public static Builder communication(int status, TaskContext context, Exception ex) { - return new Builder(COMM_TYPE, status) - .instance(context.position().jsonPointer()) - .title(ex.getMessage()); + return communication(status, context, ex.getMessage()); + } + + public static Builder communication(int status, TaskContext context, String title) { + return new Builder(COMM_TYPE, status).instance(context.position().jsonPointer()).title(title); } public static Builder runtime(int status, TaskContext context, Exception ex) { diff --git a/impl/http/pom.xml b/impl/http/pom.xml index 516669a3..4b128a45 100644 --- a/impl/http/pom.xml +++ b/impl/http/pom.xml @@ -16,6 +16,10 @@ io.serverlessworkflow serverlessworkflow-impl-core + + io.serverlessworkflow + serverlessworkflow-jwt + org.glassfish.jersey.media jersey-media-json-jackson @@ -34,6 +38,11 @@ serverlessworkflow-impl-jackson test + + io.serverlessworkflow + serverlessworkflow-impl-jackson-jwt + test + org.junit.jupiter junit-jupiter-api @@ -55,5 +64,10 @@ logback-classic test + + com.squareup.okhttp3 + mockwebserver + test + \ No newline at end of file diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AuthProvider.java index 7a902435..ef90301d 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/AuthProvider.java @@ -20,8 +20,17 @@ import io.serverlessworkflow.impl.WorkflowModel; import jakarta.ws.rs.client.Invocation; -@FunctionalInterface interface AuthProvider { + + default void preRequest( + Invocation.Builder builder, WorkflowContext workflow, TaskContext task, WorkflowModel model) { + // Default implementation does nothing + } + + default void postRequest(WorkflowContext workflow, TaskContext task, WorkflowModel model) { + // Default implementation does nothing + } + Invocation.Builder build( Invocation.Builder builder, WorkflowContext workflow, TaskContext task, WorkflowModel model); } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java index ee812055..80d6f14f 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/HttpExecutor.java @@ -154,7 +154,10 @@ public CompletableFuture apply( return CompletableFuture.supplyAsync( () -> { try { - return requestFunction.apply(request, workflow, taskContext, input); + authProvider.ifPresent(auth -> auth.preRequest(request, workflow, taskContext, input)); + WorkflowModel result = requestFunction.apply(request, workflow, taskContext, input); + authProvider.ifPresent(auth -> auth.postRequest(workflow, taskContext, input)); + return result; } catch (WebApplicationException exception) { throw new WorkflowException( WorkflowError.communication( diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java index 9a558be2..05aa263d 100644 --- a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java @@ -16,24 +16,46 @@ package io.serverlessworkflow.impl.executors.http; import io.serverlessworkflow.api.types.OAuth2AuthenticationPolicy; +import io.serverlessworkflow.api.types.Oauth2; import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.http.jwt.JWT; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; import io.serverlessworkflow.impl.WorkflowContext; import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.http.oauth.OAuthRequestBuilder; +import jakarta.ws.rs.client.Invocation; import jakarta.ws.rs.client.Invocation.Builder; public class OAuth2AuthProvider implements AuthProvider { + private OAuthRequestBuilder requestBuilder; + + private static final String BEARER_TOKEN = "%s %s"; + public OAuth2AuthProvider( - WorkflowApplication app, Workflow workflow, OAuth2AuthenticationPolicy authPolicy) { - throw new UnsupportedOperationException("Oauth2 auth not supported yet"); + WorkflowApplication application, Workflow workflow, OAuth2AuthenticationPolicy authPolicy) { + Oauth2 oauth2 = authPolicy.getOauth2(); + if (oauth2.getOAuth2ConnectAuthenticationProperties() != null) { + this.requestBuilder = new OAuthRequestBuilder(application, oauth2); + } else if (oauth2.getOAuth2AuthenticationPolicySecret() != null) { + throw new UnsupportedOperationException("Secrets are still not supported"); + } } @Override public Builder build( Builder builder, WorkflowContext workflow, TaskContext task, WorkflowModel model) { - // TODO Auto-generated method stub return builder; } + + @Override + public void preRequest( + Invocation.Builder builder, WorkflowContext workflow, TaskContext task, WorkflowModel model) { + JWT token = requestBuilder.build(workflow, task, model).validateAndGet(); + String tokenType = (String) token.getClaim("typ"); + builder.header( + AuthProviderFactory.AUTH_HEADER_NAME, + String.format(BEARER_TOKEN, tokenType, token.getToken())); + } } diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/AccessTokenProvider.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/AccessTokenProvider.java new file mode 100644 index 00000000..0b4f6cce --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/AccessTokenProvider.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import io.serverlessworkflow.http.jwt.JWT; +import io.serverlessworkflow.http.jwt.JWTConverter; +import io.serverlessworkflow.impl.TaskContext; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +public class AccessTokenProvider { + + private final TokenResponseHandler tokenResponseHandler = new TokenResponseHandler(); + + private final TaskContext context; + private final List issuers; + private final InvocationHolder invocation; + + private final JWTConverter jwtConverter; + + AccessTokenProvider(InvocationHolder invocation, TaskContext context, List issuers) { + this.invocation = invocation; + this.issuers = issuers; + this.context = context; + + this.jwtConverter = + ServiceLoader.load(JWTConverter.class) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No JWTConverter implementation found")); + } + + public JWT validateAndGet() { + Map token = tokenResponseHandler.apply(invocation, context); + JWT jwt = jwtConverter.fromToken((String) token.get("access_token")); + if (!(issuers == null || issuers.isEmpty())) { + String tokenIssuer = (String) jwt.getClaim("iss"); + if (tokenIssuer == null || tokenIssuer.isEmpty() || !issuers.contains(tokenIssuer)) { + throw new IllegalStateException("Token issuer is not valid: " + tokenIssuer); + } + } + return jwt; + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretBasic.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretBasic.java new file mode 100644 index 00000000..6dfbe260 --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretBasic.java @@ -0,0 +1,89 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import static io.serverlessworkflow.api.types.OAuth2AutenthicationData.OAuth2AutenthicationDataGrant.CLIENT_CREDENTIALS; +import static io.serverlessworkflow.api.types.OAuth2AutenthicationData.OAuth2AutenthicationDataGrant.PASSWORD; + +import io.serverlessworkflow.api.types.OAuth2AutenthicationData; +import io.serverlessworkflow.api.types.Oauth2; +import java.util.Base64; + +class ClientSecretBasic { + + private final Oauth2 oauth2; + + public ClientSecretBasic(Oauth2 oauth2) { + this.oauth2 = oauth2; + } + + public void execute(HttpRequestBuilder requestBuilder) { + OAuth2AutenthicationData authenticationData = + oauth2.getOAuth2ConnectAuthenticationProperties().getOAuth2AutenthicationData(); + if (authenticationData.getGrant().equals(PASSWORD)) { + password(requestBuilder, authenticationData); + } else if (authenticationData.getGrant().equals(CLIENT_CREDENTIALS)) { + clientCredentials(requestBuilder, authenticationData); + } else { + throw new UnsupportedOperationException( + "Unsupported grant type: " + authenticationData.getGrant()); + } + } + + private void clientCredentials( + HttpRequestBuilder requestBuilder, OAuth2AutenthicationData authenticationData) { + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + + String idAndSecret = + authenticationData.getClient().getId() + ":" + authenticationData.getClient().getSecret(); + String encodedAuth = Base64.getEncoder().encodeToString(idAndSecret.getBytes()); + + requestBuilder + .addHeader("Authorization", "Basic " + encodedAuth) + .withRequestContentType(authenticationData.getRequest()) + .withGrantType(authenticationData.getGrant()); + } + + private void password( + HttpRequestBuilder requestBuilder, OAuth2AutenthicationData authenticationData) { + if (authenticationData.getUsername() == null || authenticationData.getPassword() == null) { + throw new IllegalArgumentException( + "Username and password must be provided for password grant type"); + } + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + + String idAndSecret = + authenticationData.getClient().getId() + ":" + authenticationData.getClient().getSecret(); + String encodedAuth = Base64.getEncoder().encodeToString(idAndSecret.getBytes()); + + requestBuilder + .withGrantType(authenticationData.getGrant()) + .withRequestContentType(authenticationData.getRequest()) + .addHeader("Authorization", "Basic " + encodedAuth) + .addQueryParam("username", authenticationData.getUsername()) + .addQueryParam("password", authenticationData.getPassword()); + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretPostStep.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretPostStep.java new file mode 100644 index 00000000..e00ca28a --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/ClientSecretPostStep.java @@ -0,0 +1,82 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import static io.serverlessworkflow.api.types.OAuth2AutenthicationData.OAuth2AutenthicationDataGrant.CLIENT_CREDENTIALS; +import static io.serverlessworkflow.api.types.OAuth2AutenthicationData.OAuth2AutenthicationDataGrant.PASSWORD; + +import io.serverlessworkflow.api.types.OAuth2AutenthicationData; +import io.serverlessworkflow.api.types.Oauth2; + +class ClientSecretPostStep { + private final Oauth2 oauth2; + + public ClientSecretPostStep(Oauth2 oauth2) { + this.oauth2 = oauth2; + } + + public void execute(HttpRequestBuilder requestBuilder) { + OAuth2AutenthicationData authenticationData = + oauth2.getOAuth2ConnectAuthenticationProperties().getOAuth2AutenthicationData(); + + if (authenticationData.getGrant().equals(PASSWORD)) { + password(requestBuilder, authenticationData); + } else if (authenticationData.getGrant().equals(CLIENT_CREDENTIALS)) { + clientCredentials(requestBuilder, authenticationData); + } else { + throw new UnsupportedOperationException( + "Unsupported grant type: " + authenticationData.getGrant()); + } + } + + private void clientCredentials( + HttpRequestBuilder requestBuilder, OAuth2AutenthicationData authenticationData) { + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + + requestBuilder + .withGrantType(authenticationData.getGrant()) + .withRequestContentType(authenticationData.getRequest()) + .addQueryParam("client_id", authenticationData.getClient().getId()) + .addQueryParam("client_secret", authenticationData.getClient().getSecret()); + } + + private void password( + HttpRequestBuilder requestBuilder, OAuth2AutenthicationData authenticationData) { + if (authenticationData.getUsername() == null || authenticationData.getPassword() == null) { + throw new IllegalArgumentException( + "Username and password must be provided for password grant type"); + } + if (authenticationData.getClient() == null + || authenticationData.getClient().getId() == null + || authenticationData.getClient().getSecret() == null) { + throw new IllegalArgumentException( + "Client ID and secret must be provided for client authentication"); + } + + requestBuilder + .withGrantType(authenticationData.getGrant()) + .withRequestContentType(authenticationData.getRequest()) + .addQueryParam("client_id", authenticationData.getClient().getId()) + .addQueryParam("client_secret", authenticationData.getClient().getSecret()) + .addQueryParam("username", authenticationData.getUsername()) + .addQueryParam("password", authenticationData.getPassword()); + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/HttpRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/HttpRequestBuilder.java new file mode 100644 index 00000000..fff003d6 --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/HttpRequestBuilder.java @@ -0,0 +1,137 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import static io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding; +import static io.serverlessworkflow.api.types.OAuth2TokenRequest.Oauth2TokenRequestEncoding.APPLICATION_X_WWW_FORM_URLENCODED; + +import io.serverlessworkflow.api.types.OAuth2AutenthicationData; +import io.serverlessworkflow.api.types.OAuth2TokenRequest; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.MediaType; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +class HttpRequestBuilder { + + private final Map> headers; + + private final Map> queryParams; + + private final WorkflowApplication app; + + private URI uri; + + private OAuth2AutenthicationData.OAuth2AutenthicationDataGrant grantType; + + private Oauth2TokenRequestEncoding requestContentType = APPLICATION_X_WWW_FORM_URLENCODED; + + HttpRequestBuilder(WorkflowApplication app) { + this.app = app; + headers = new HashMap<>(); + queryParams = new HashMap<>(); + } + + HttpRequestBuilder addHeader(String key, String token) { + headers.put(key, WorkflowUtils.buildStringFilter(app, token)); + return this; + } + + HttpRequestBuilder addQueryParam(String key, String token) { + queryParams.put(key, WorkflowUtils.buildStringFilter(app, token)); + return this; + } + + HttpRequestBuilder withUri(URI uri) { + this.uri = uri; + return this; + } + + HttpRequestBuilder withRequestContentType(OAuth2TokenRequest oAuth2TokenRequest) { + if (oAuth2TokenRequest != null) { + this.requestContentType = oAuth2TokenRequest.getEncoding(); + } + return this; + } + + HttpRequestBuilder withGrantType( + OAuth2AutenthicationData.OAuth2AutenthicationDataGrant grantType) { + this.grantType = grantType; + return this; + } + + InvocationHolder build(WorkflowContext workflow, TaskContext task, WorkflowModel model) { + validate(); + + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(uri); + + Invocation.Builder builder = target.request(MediaType.APPLICATION_JSON); + + builder.header("grant_type", grantType.name().toLowerCase()); + builder.header("User-Agent", "OAuth2-Client-Credentials/1.0"); + builder.header("Accept", MediaType.APPLICATION_JSON); + builder.header("Cache-Control", "no-cache"); + + for (var entry : headers.entrySet()) { + String headerValue = entry.getValue().apply(workflow, task, model); + if (headerValue != null) { + builder.header(entry.getKey(), headerValue); + } + } + + Entity entity; + if (requestContentType.equals(APPLICATION_X_WWW_FORM_URLENCODED)) { + Form form = new Form(); + form.param("grant_type", grantType.value()); + queryParams.forEach( + (key, value) -> { + String resolved = value.apply(workflow, task, model); + form.param(key, resolved); + }); + entity = Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED); + } else { + Map jsonData = new HashMap<>(); + jsonData.put("grant_type", grantType.value()); + queryParams.forEach( + (key, value) -> { + String resolved = value.apply(workflow, task, model); + jsonData.put(key, resolved); + }); + entity = Entity.entity(jsonData, MediaType.APPLICATION_JSON); + } + + return new InvocationHolder(client, () -> builder.post(entity)); + } + + private void validate() { + Objects.requireNonNull(uri, "URI must be set before building the request"); + Objects.requireNonNull(grantType, "Grant type must be set before building the request"); + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/InvocationHolder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/InvocationHolder.java new file mode 100644 index 00000000..6655be8e --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/InvocationHolder.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.core.Response; +import java.io.Closeable; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +class InvocationHolder implements Callable, Closeable { + + private final Client client; + private final Supplier call; + + InvocationHolder(Client client, Supplier call) { + this.client = client; + this.call = call; + } + + public Response call() { + return call.get(); + } + + public void close() { + if (client != null) { + client.close(); + } + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/OAuthRequestBuilder.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/OAuthRequestBuilder.java new file mode 100644 index 00000000..c78e16f2 --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/OAuthRequestBuilder.java @@ -0,0 +1,166 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import static io.serverlessworkflow.api.types.OAuth2AutenthicationDataClient.ClientAuthentication.CLIENT_SECRET_POST; + +import io.serverlessworkflow.api.types.OAuth2AutenthicationData; +import io.serverlessworkflow.api.types.OAuth2AutenthicationDataClient; +import io.serverlessworkflow.api.types.OAuth2AuthenticationPropertiesEndpoints; +import io.serverlessworkflow.api.types.Oauth2; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class OAuthRequestBuilder { + + private final Oauth2 oauth2; + + private final OAuth2AutenthicationData authenticationData; + + private final WorkflowApplication application; + + private List issuers; + + private final Map defaults = + Map.of( + "endpoints.token", "oauth2/token", + "endpoints.revocation", "oauth2/revoke", + "endpoints.introspection", "oauth2/introspect"); + + public OAuthRequestBuilder(WorkflowApplication application, Oauth2 oauth2) { + this.oauth2 = oauth2; + this.authenticationData = + oauth2.getOAuth2ConnectAuthenticationProperties().getOAuth2AutenthicationData(); + this.application = application; + } + + public AccessTokenProvider build( + WorkflowContext workflow, TaskContext task, WorkflowModel model) { + HttpRequestBuilder requestBuilder = new HttpRequestBuilder(application); + + requestEncoding(requestBuilder); + authenticationURI(requestBuilder); + audience(requestBuilder); + scope(requestBuilder); + issuers(); + authenticationMethod(requestBuilder); + + return new AccessTokenProvider(requestBuilder.build(workflow, task, model), task, issuers); + } + + private void authenticationMethod(HttpRequestBuilder requestBuilder) { + switch (getClientAuthentication()) { + case CLIENT_SECRET_BASIC: + clientSecretBasic(requestBuilder); + case CLIENT_SECRET_JWT: + throw new UnsupportedOperationException("Client Secret JWT is not supported yet"); + case PRIVATE_KEY_JWT: + throw new UnsupportedOperationException("Private Key JWT is not supported yet"); + default: + clientSecretPost(requestBuilder); + } + } + + private void clientSecretBasic(HttpRequestBuilder requestBuilder) { + new ClientSecretBasic(oauth2).execute(requestBuilder); + } + + private void clientSecretPost(HttpRequestBuilder requestBuilder) { + new ClientSecretPostStep(oauth2).execute(requestBuilder); + } + + private OAuth2AutenthicationDataClient.ClientAuthentication getClientAuthentication() { + if (authenticationData.getClient() == null + || authenticationData.getClient().getAuthentication() == null) { + return CLIENT_SECRET_POST; + } + return authenticationData.getClient().getAuthentication(); + } + + private void issuers() { + issuers = + oauth2 + .getOAuth2ConnectAuthenticationProperties() + .getOAuth2AutenthicationData() + .getIssuers(); + } + + public void audience(HttpRequestBuilder requestBuilder) { + if (authenticationData.getAudiences() != null && !authenticationData.getAudiences().isEmpty()) { + String audiences = String.join(" ", authenticationData.getAudiences()); + requestBuilder.addQueryParam("audience", audiences); + } + } + + private void scope(HttpRequestBuilder requestBuilder) { + List scopesList = authenticationData.getScopes(); + if (scopesList == null || scopesList.isEmpty()) { + return; + } + String scope = + scopesList.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .flatMap(s -> Arrays.stream(s.split("\\s+"))) + .distinct() + .collect(Collectors.joining(" ")); + + if (!scope.isEmpty()) { + requestBuilder.addQueryParam("scope", scope); + } + } + + private void authenticationURI(HttpRequestBuilder requestBuilder) { + OAuth2AuthenticationPropertiesEndpoints endpoints = + oauth2 + .getOAuth2ConnectAuthenticationProperties() + .getOAuth2ConnectAuthenticationProperties() + .getEndpoints(); + + String baseUri = + oauth2 + .getOAuth2ConnectAuthenticationProperties() + .getOAuth2AutenthicationData() + .getAuthority() + .getLiteralUri() + .toString() + .replaceAll("/$", ""); + String tokenPath = defaults.get("endpoints.token"); + if (endpoints != null && endpoints.getToken() != null) { + tokenPath = endpoints.getToken().replaceAll("^/", ""); + } + requestBuilder.withUri(URI.create(baseUri + "/" + tokenPath)); + } + + public void requestEncoding(HttpRequestBuilder requestBuilder) { + if (authenticationData.getRequest() != null + && authenticationData.getRequest().getEncoding() != null) { + requestBuilder.addHeader( + "Content-Type", authenticationData.getRequest().getEncoding().value()); + } else { + requestBuilder.addHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + } + } +} diff --git a/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/TokenResponseHandler.java b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/TokenResponseHandler.java new file mode 100644 index 00000000..a04998e8 --- /dev/null +++ b/impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/TokenResponseHandler.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.executors.http.oauth; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.ResponseProcessingException; +import jakarta.ws.rs.core.GenericType; +import jakarta.ws.rs.core.Response; +import java.util.Map; +import java.util.function.BiFunction; + +public class TokenResponseHandler + implements BiFunction> { + + @Override + public Map apply(InvocationHolder invocation, TaskContext context) { + try (Response response = invocation.call()) { + if (response.getStatus() < 200 || response.getStatus() >= 300) { + throw new WorkflowException( + WorkflowError.communication( + response.getStatus(), + context, + "Failed to obtain token: HTTP " + + response.getStatus() + + " — " + + response.getEntity()) + .build()); + } + return response.readEntity(new GenericType<>() {}); + } catch (ResponseProcessingException e) { + throw new WorkflowException( + WorkflowError.communication( + e.getResponse().getStatus(), + context, + "Failed to process response: " + e.getMessage()) + .build(), + e); + } catch (ProcessingException e) { + throw new WorkflowException( + WorkflowError.communication( + -1, context, "Failed to connect or process request: " + e.getMessage()) + .build(), + e); + } finally { + invocation.close(); + } + } +} diff --git a/impl/http/src/test/java/io/serverlessworkflow/impl/OAuthHTTPWorkflowDefinitionTest.java b/impl/http/src/test/java/io/serverlessworkflow/impl/OAuthHTTPWorkflowDefinitionTest.java new file mode 100644 index 00000000..aa9d5859 --- /dev/null +++ b/impl/http/src/test/java/io/serverlessworkflow/impl/OAuthHTTPWorkflowDefinitionTest.java @@ -0,0 +1,900 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl; + +import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.serverlessworkflow.api.types.Workflow; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class OAuthHTTPWorkflowDefinitionTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String RESPONSE = + """ + { + "message": "Hello World" + } + """; + + String TOKEN_RESPONSE_TEMPLATE = + """ + { + "access_token": "%s", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write" + } + """; + + private MockWebServer authServer; + private MockWebServer apiServer; + private OkHttpClient httpClient; + private String authBaseUrl; + private String apiBaseUrl; + + @BeforeEach + void setUp() throws IOException { + authServer = new MockWebServer(); + authServer.start(8888); + authBaseUrl = "http://localhost:8888"; + + apiServer = new MockWebServer(); + apiServer.start(8081); + apiBaseUrl = "http://localhost:8081"; + + httpClient = new OkHttpClient(); + } + + @AfterEach + void tearDown() throws IOException { + authServer.shutdown(); + apiServer.shutdown(); + } + + @Test + public void testOAuthClientSecretPostPasswordWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthClientSecretPostPasswordHttpCall.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=password")); + assertTrue(tokenRequestBody.contains("username=serverless-workflow-test")); + assertTrue(tokenRequestBody.contains("password=serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostWithArgsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthClientSecretPostPasswordAsArgHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=password")); + assertTrue(tokenRequestBody.contains("username=serverless-workflow-test")); + assertTrue(tokenRequestBody.contains("password=serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostWithArgsNoEndPointWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=password")); + assertTrue(tokenRequestBody.contains("username=serverless-workflow-test")); + assertTrue(tokenRequestBody.contains("password=serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostWithArgsAllGrantsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test", + "openidScope", "openidScope", + "audience", "account"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=password")); + assertTrue(tokenRequestBody.contains("username=serverless-workflow-test")); + assertTrue(tokenRequestBody.contains("password=serverless-workflow-test")); + + assertTrue( + tokenRequestBody.contains("scope=pets%3Aread+pets%3Awrite+pets%3Adelete+pets%3Acreate")); + assertTrue( + tokenRequestBody.contains("audience=serverless-workflow+another-audience+third-audience")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostClientCredentialsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthClientSecretPostClientCredentialsHttpCall.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=client_credentials")); + assertTrue(tokenRequestBody.contains("client_id=serverless-workflow")); + assertTrue(tokenRequestBody.contains("secret=D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostClientCredentialsParamsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=client_credentials")); + assertTrue(tokenRequestBody.contains("client_id=serverless-workflow")); + assertTrue(tokenRequestBody.contains("secret=D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthClientSecretPostClientCredentialsParamsNoEndpointWorkflowExecution() + throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath( + "oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/x-www-form-urlencoded", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + assertTrue(tokenRequestBody.contains("grant_type=client_credentials")); + assertTrue(tokenRequestBody.contains("client_id=serverless-workflow")); + assertTrue(tokenRequestBody.contains("secret=D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONPasswordWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordHttpCall.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue(asJson.containsKey("grant_type") && asJson.get("grant_type").equals("password")); + + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + assertTrue( + asJson.containsKey("username") + && asJson.get("username").equals("serverless-workflow-test")); + assertTrue( + asJson.containsKey("password") + && asJson.get("password").equals("serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONWithArgsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordAsArgHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue(asJson.containsKey("grant_type") && asJson.get("grant_type").equals("password")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + assertTrue( + asJson.containsKey("username") + && asJson.get("username").equals("serverless-workflow-test")); + assertTrue( + asJson.containsKey("password") + && asJson.get("password").equals("serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONWithArgsNoEndPointWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordNoEndpointsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue(asJson.containsKey("grant_type") && asJson.get("grant_type").equals("password")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + assertTrue( + asJson.containsKey("username") + && asJson.get("username").equals("serverless-workflow-test")); + assertTrue( + asJson.containsKey("password") + && asJson.get("password").equals("serverless-workflow-test")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONWithArgsAllGrantsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONPasswordAllGrantsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT", + "username", "serverless-workflow-test", + "password", "serverless-workflow-test", + "openidScope", "openidScope", + "audience", "account"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue(asJson.containsKey("grant_type") && asJson.get("grant_type").equals("password")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + assertTrue( + asJson.containsKey("username") + && asJson.get("username").equals("serverless-workflow-test")); + assertTrue( + asJson.containsKey("password") + && asJson.get("password").equals("serverless-workflow-test")); + + assertTrue( + asJson.containsKey("scope") + && asJson.get("scope").equals("pets:read pets:write pets:delete pets:create")); + + assertTrue( + asJson.containsKey("audience") + && asJson + .get("audience") + .equals("serverless-workflow another-audience third-audience")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONClientCredentialsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONClientCredentialsHttpCall.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue( + asJson.containsKey("grant_type") && asJson.get("grant_type").equals("client_credentials")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONClientCredentialsParamsWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = readWorkflowFromClasspath("oAuthJSONClientCredentialsParamsHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/protocol/openid-connect/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue( + asJson.containsKey("grant_type") && asJson.get("grant_type").equals("client_credentials")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + @Test + public void testOAuthJSONClientCredentialsParamsNoEndpointWorkflowExecution() throws Exception { + String jwt = fakeAccessToken(); + + String tokenResponse = TOKEN_RESPONSE_TEMPLATE.formatted(jwt); + + authServer.enqueue( + new MockResponse() + .setBody(tokenResponse) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + apiServer.enqueue( + new MockResponse() + .setBody(RESPONSE) + .setHeader("Content-Type", "application/json") + .setResponseCode(200)); + + Workflow workflow = + readWorkflowFromClasspath("oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml"); + Map result; + Map params = + Map.of( + "clientId", "serverless-workflow", + "clientSecret", "D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT"); + + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(params).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertTrue(result.containsKey("message")); + assertTrue(result.get("message").toString().contains("Hello World")); + + RecordedRequest tokenRequest = authServer.takeRequest(); + assertEquals("POST", tokenRequest.getMethod()); + assertEquals("/realms/test-realm/oauth2/token", tokenRequest.getPath()); + assertEquals("application/json", tokenRequest.getHeader("Content-Type")); + + String tokenRequestBody = tokenRequest.getBody().readUtf8(); + Map asJson = MAPPER.readValue(tokenRequestBody, Map.class); + assertTrue( + asJson.containsKey("grant_type") && asJson.get("grant_type").equals("client_credentials")); + assertTrue( + asJson.containsKey("client_id") && asJson.get("client_id").equals("serverless-workflow")); + assertTrue( + asJson.containsKey("client_secret") + && asJson.get("client_secret").equals("D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT")); + + RecordedRequest petRequest = apiServer.takeRequest(); + assertEquals("GET", petRequest.getMethod()); + assertEquals("/hello", petRequest.getPath()); + assertEquals("Bearer " + jwt, petRequest.getHeader("Authorization")); + } + + public static String fakeJwt(Map payload) throws Exception { + String headerJson = + MAPPER.writeValueAsString( + Map.of( + "alg", "RS256", + "typ", "JWT", + "kid", "test")); + String payloadJson = MAPPER.writeValueAsString(payload); + return b64Url(headerJson) + "." + b64Url(payloadJson) + ".sig"; + } + + private static String b64Url(String s) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } + + public static String fakeAccessToken() throws Exception { + long now = Instant.now().getEpochSecond(); + return fakeJwt( + Map.of( + "iss", "http://localhost:8888/realms/test-realm", + "aud", "account", + "sub", "test-subject", + "azp", "serverless-workflow", + "typ", "Bearer", + "scope", "profile email", + "exp", now + 3600, + "iat", now)); + } +} diff --git a/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsHttpCall.yaml new file mode 100644 index 00000000..6ba976c8 --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsHttpCall.yaml @@ -0,0 +1,23 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: client_credentials + client: + id: serverless-workflow + secret: D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml new file mode 100644 index 00000000..9a441fcc --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsHttpCall.yaml @@ -0,0 +1,25 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: client_credentials + request: + encoding: application/x-www-form-urlencoded + client: + id: ${ .clientId } + secret: ${ .clientSecret } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml new file mode 100644 index 00000000..5e616d34 --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostClientCredentialsParamsNoEndPointHttpCall.yaml @@ -0,0 +1,21 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: client_credentials + client: + id: ${ .clientId } + secret: ${ .clientSecret } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml new file mode 100644 index 00000000..ff350683 --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostPasswordAllGrantsHttpCall.yaml @@ -0,0 +1,29 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: password + request: + encoding: application/x-www-form-urlencoded + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } + scopes: + - pets:read + - pets:write + - pets:delete + - pets:create + audiences: [ serverless-workflow, another-audience, third-audience ] \ No newline at end of file diff --git a/impl/http/src/test/resources/oAuthClientSecretPostPasswordAsArgHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostPasswordAsArgHttpCall.yaml new file mode 100644 index 00000000..817844a3 --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostPasswordAsArgHttpCall.yaml @@ -0,0 +1,25 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: password + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthClientSecretPostPasswordHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostPasswordHttpCall.yaml new file mode 100644 index 00000000..891ce62f --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostPasswordHttpCall.yaml @@ -0,0 +1,25 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: password + client: + id: serverless-workflow + secret: D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT + username: serverless-workflow-test + password: serverless-workflow-test + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml b/impl/http/src/test/resources/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml new file mode 100644 index 00000000..3a0fc402 --- /dev/null +++ b/impl/http/src/test/resources/oAuthClientSecretPostPasswordNoEndpointsHttpCall.yaml @@ -0,0 +1,21 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: password + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } diff --git a/impl/http/src/test/resources/oAuthJSONClientCredentialsHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONClientCredentialsHttpCall.yaml new file mode 100644 index 00000000..5c50cfd9 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONClientCredentialsHttpCall.yaml @@ -0,0 +1,25 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: client_credentials + request: + encoding: application/json + client: + id: serverless-workflow + secret: D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsHttpCall.yaml new file mode 100644 index 00000000..d6480d03 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsHttpCall.yaml @@ -0,0 +1,25 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: client_credentials + request: + encoding: application/json + client: + id: ${ .clientId } + secret: ${ .clientSecret } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml new file mode 100644 index 00000000..8c442372 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONClientCredentialsParamsNoEndPointHttpCall.yaml @@ -0,0 +1,23 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: client_credentials + request: + encoding: application/json + client: + id: ${ .clientId } + secret: ${ .clientSecret } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthJSONPasswordAllGrantsHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONPasswordAllGrantsHttpCall.yaml new file mode 100644 index 00000000..b5546842 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONPasswordAllGrantsHttpCall.yaml @@ -0,0 +1,29 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: password + request: + encoding: application/json + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } + scopes: + - pets:read + - pets:write + - pets:delete + - pets:create + audiences: [ serverless-workflow, another-audience, third-audience ] \ No newline at end of file diff --git a/impl/http/src/test/resources/oAuthJSONPasswordAsArgHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONPasswordAsArgHttpCall.yaml new file mode 100644 index 00000000..c9864aa9 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONPasswordAsArgHttpCall.yaml @@ -0,0 +1,27 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: password + request: + encoding: application/json + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthJSONPasswordHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONPasswordHttpCall.yaml new file mode 100644 index 00000000..fa5d3edc --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONPasswordHttpCall.yaml @@ -0,0 +1,27 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + endpoints: + token: protocol/openid-connect/token + grant: password + request: + encoding: application/json + client: + id: serverless-workflow + secret: D0ACXCUKOUrL5YL7j6RQWplMaSjPB8MT + username: serverless-workflow-test + password: serverless-workflow-test + issuers: + - http://localhost:8888/realms/test-realm diff --git a/impl/http/src/test/resources/oAuthJSONPasswordNoEndpointsHttpCall.yaml b/impl/http/src/test/resources/oAuthJSONPasswordNoEndpointsHttpCall.yaml new file mode 100644 index 00000000..91da2890 --- /dev/null +++ b/impl/http/src/test/resources/oAuthJSONPasswordNoEndpointsHttpCall.yaml @@ -0,0 +1,23 @@ +document: + dsl: '1.0.0-alpha5' + namespace: examples + name: oauth2-authentication + version: '0.1.0' +do: + - getPet: + call: http + with: + method: get + endpoint: + uri: http://localhost:8081/hello + authentication: + oauth2: + authority: http://localhost:8888/realms/test-realm + grant: password + request: + encoding: application/json + client: + id: ${ .clientId } + secret: ${ .clientSecret } + username: ${ .username } + password: ${ .password } diff --git a/impl/jwt-impl/pom.xml b/impl/jwt-impl/pom.xml new file mode 100644 index 00000000..64795e76 --- /dev/null +++ b/impl/jwt-impl/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + + serverlessworkflow-impl-jackson-jwt + Serverless Workflow :: Impl :: JWT + + + + io.serverlessworkflow + serverlessworkflow-jwt + + + com.fasterxml.jackson.core + jackson-databind + + + + \ No newline at end of file diff --git a/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTConverter.java b/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTConverter.java new file mode 100644 index 00000000..850538ab --- /dev/null +++ b/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTConverter.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.http.jwt; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.serverlessworkflow.http.jwt.JWT; +import io.serverlessworkflow.http.jwt.JWTConverter; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +public class JacksonJWTConverter implements JWTConverter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public JWT fromToken(String token) throws IllegalArgumentException { + String[] parts = token.split("\\."); + if (parts.length < 2) { + throw new IllegalArgumentException("Invalid JWT token format"); + } + try { + String payloadJson = + new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + return new JacksonJWTImpl(token, MAPPER.readValue(payloadJson, Map.class)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse JWT token payload: " + e.getMessage(), e); + } + } +} diff --git a/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTImpl.java b/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTImpl.java new file mode 100644 index 00000000..9101646f --- /dev/null +++ b/impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTImpl.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.http.jwt; + +import io.serverlessworkflow.http.jwt.JWT; +import java.util.Map; + +public class JacksonJWTImpl implements JWT { + + private final Map claims; + private final String token; + + JacksonJWTImpl(String token, Map claims) { + this.token = token; + this.claims = claims; + } + + @Override + public String getToken() { + return token; + } + + @Override + public Object getClaim(String name) { + if (claims == null || claims.isEmpty()) { + return null; + } + return claims.get(name); + } +} diff --git a/impl/jwt-impl/src/main/resources/META-INF/services/io.serverlessworkflow.http.jwt.JWTConverter b/impl/jwt-impl/src/main/resources/META-INF/services/io.serverlessworkflow.http.jwt.JWTConverter new file mode 100644 index 00000000..1c898a8b --- /dev/null +++ b/impl/jwt-impl/src/main/resources/META-INF/services/io.serverlessworkflow.http.jwt.JWTConverter @@ -0,0 +1 @@ +io.serverlessworkflow.impl.http.jwt.JacksonJWTConverter \ No newline at end of file diff --git a/impl/pom.xml b/impl/pom.xml index 416e26ef..db10ad78 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -75,5 +75,6 @@ http core jackson + jwt-impl \ No newline at end of file diff --git a/jwt/pom.xml b/jwt/pom.xml new file mode 100644 index 00000000..f3beea6c --- /dev/null +++ b/jwt/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-parent + 8.0.0-SNAPSHOT + + + serverlessworkflow-jwt + Serverless Workflow :: JWT + jar + \ No newline at end of file diff --git a/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWT.java b/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWT.java new file mode 100644 index 00000000..8d2f19da --- /dev/null +++ b/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWT.java @@ -0,0 +1,23 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.http.jwt; + +public interface JWT { + + String getToken(); + + Object getClaim(String name); +} diff --git a/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWTConverter.java b/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWTConverter.java new file mode 100644 index 00000000..fd3d759e --- /dev/null +++ b/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWTConverter.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.http.jwt; + +public interface JWTConverter { + + /** + * Converts a JWT token string into a JWT object. + * + * @param token the JWT token string + * @return a JWT object containing the token and its claims + */ + JWT fromToken(String token) throws IllegalArgumentException; +} diff --git a/pom.xml b/pom.xml index 75e32710..f0775854 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,7 @@ examples experimental fluent + jwt @@ -78,6 +79,7 @@ 1.5.18 2.19.2 1.5.8 + 4.12.0 3.1.1 1.5.2 3.27.4 @@ -166,6 +168,16 @@ serverlessworkflow-api ${project.version} + + io.serverlessworkflow + serverlessworkflow-jwt + ${project.version} + + + io.serverlessworkflow + serverlessworkflow-impl-jackson-jwt + ${project.version} + com.networknt json-schema-validator @@ -223,6 +235,12 @@ ${version.ch.qos.logback} test + + com.squareup.okhttp3 + mockwebserver + ${version.com.squareup.okhttp3.mockwebserver} + test + org.assertj assertj-core