From b5ebb4ee3dcf774d0bc6f8dbe66433f3cde55a96 Mon Sep 17 00:00:00 2001 From: Brian Demers Date: Fri, 13 Feb 2026 17:06:46 -0500 Subject: [PATCH] Add AuthService util methods to make getting started with Arcade easier Adds `client.auth().start(...)` and `client.auth().waitForCompletion(...)` In the async client: `asyncClient.auth().start()`, async clients must handle blocking polling call manually. --- README.md | 35 +++ .../arcade/services/async/AuthServiceAsync.kt | 21 ++ .../services/async/AuthServiceAsyncImpl.kt | 33 +++ .../arcade/services/blocking/AuthService.kt | 39 ++++ .../services/blocking/AuthServiceImpl.kt | 64 ++++++ .../services/async/AuthServiceAsyncTest.kt | 104 +++++++++ .../services/blocking/AuthServiceTest.kt | 207 ++++++++++++++++++ .../java/dev/arcade/example/AuthExample.java | 38 ++++ 8 files changed, 541 insertions(+) create mode 100644 arcade-java-example/src/main/java/dev/arcade/example/AuthExample.java diff --git a/README.md b/README.md index dd0d447..80607e1 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,41 @@ ArcadeClient clientWithOptions = client.withOptions(optionsBuilder -> { The `withOptions()` method does not affect the original client or service. +## User Authentication + +To initiate an OAuth 2 authenticated flow with a user, use the `AuthService.start` method: + +```java +import dev.arcade.client.ArcadeClient; +import dev.arcade.models.AuthorizationResponse; + +// See above on creating a client +ArcadeClient client; + +// get the auth service, and call start +AuthorizationResponse authResponse = client.auth().start( + "{arcade_user_id}", // email or user ID of an Arcade user + "{auth_provider}", // provider name + "oauth2", // provider type + List.of("{scope1}", "{scope2}")); // list of scopes + +// check the response status +authResponse.status() + .filter(status -> status != AuthorizationResponse.Status.COMPLETED) + .ifPresent(status -> + System.out.println("Click this link to authorize: " + authResponse.url().get())); +``` +```java +// if the authorization is NOT complete, you can wait using the following method: +client.auth().waitForCompletion(authResponse); +``` + +> [!CAUTION] +> This method should not be used in web applications as it will block the current thread. +> For web apps, you will need to poll the `status` endpoint by calling `client.auth.status(...)`. + +For more details, see the [Authorized Tool Calling](https://docs.arcade.dev/en/guides/tool-calling/custom-apps/auth-tool-calling) docs. + ## Requests and responses To send a request to the Arcade API, build an instance of some `Params` class and pass it to the corresponding client method. When the response is received, it will be deserialized into an instance of a Java class. diff --git a/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsync.kt b/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsync.kt index 0f09eab..9583809 100644 --- a/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsync.kt +++ b/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsync.kt @@ -177,4 +177,25 @@ interface AuthServiceAsync { requestOptions: RequestOptions = RequestOptions.none(), ): CompletableFuture> } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + /** + * Starts the authorization process for a given provider and scopes. + * + * @param userId The user ID for which authorization is being requested. + * @param provider The authorization provider (e.g., 'github', 'google', 'linkedin', + * 'microsoft', 'slack', 'spotify', 'x', 'zoom'). + * @param providerType The type of authorization provider. Optional, defaults to 'oauth2'. + * @param scopes A list of scopes required for authorization, if any. + * @return A CompletableFuture containing the authorization response based on the request. + */ + fun start( + userId: String, + provider: String, + providerType: String = "oauth2", + scopes: List = emptyList(), + ): CompletableFuture } diff --git a/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsyncImpl.kt b/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsyncImpl.kt index 0380658..9aa984f 100644 --- a/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsyncImpl.kt +++ b/arcade-java-core/src/main/kotlin/dev/arcade/services/async/AuthServiceAsyncImpl.kt @@ -18,6 +18,7 @@ import dev.arcade.core.prepareAsync import dev.arcade.models.AuthorizationResponse import dev.arcade.models.auth.AuthAuthorizeParams import dev.arcade.models.auth.AuthConfirmUserParams +import dev.arcade.models.auth.AuthRequest import dev.arcade.models.auth.AuthStatusParams import dev.arcade.models.auth.ConfirmUserResponse import java.util.concurrent.CompletableFuture @@ -161,4 +162,36 @@ class AuthServiceAsyncImpl internal constructor(private val clientOptions: Clien } } } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + override fun start( + userId: String, + provider: String, + providerType: String, + scopes: List, + ): CompletableFuture { + return authorize( + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId(userId) + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId(provider) + .providerType(providerType) + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(scopes) + .build() + ) + .build() + ) + .build() + ) + .build() + ) + } } diff --git a/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthService.kt b/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthService.kt index 1b07122..625fa2f 100644 --- a/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthService.kt +++ b/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthService.kt @@ -177,4 +177,43 @@ interface AuthService { requestOptions: RequestOptions = RequestOptions.none(), ): HttpResponseFor } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + /** + * Starts the authorization process for a given provider and scopes. + * + * @param userId The user ID for which authorization is being requested. + * @param provider The authorization provider (e.g., 'github', 'google', 'linkedin', + * 'microsoft', 'slack', 'spotify', 'x', 'zoom'). + * @param providerType The type of authorization provider. Optional, defaults to 'oauth2'. + * @param scopes A list of scopes required for authorization, if any. + * @return The authorization response based on the request. + */ + fun start( + userId: String, + provider: String, + providerType: String = "oauth2", + scopes: List = emptyList(), + ): AuthorizationResponse + + /** + * Waits for the authorization process to complete, for example, + *

+     *     val authResponse = client.auth().start("you@example.com", "github")
+     *     val authResult = client.auth().waitForCompletion(authResponse)
+     * 
+ */ + fun waitForCompletion(authorizationResponse: AuthorizationResponse): AuthorizationResponse + + /** + * Waits for the authorization process to complete, for example, + *

+     *     val authResponse = client.auth().start("you@example.com", "github")
+     *     val authResult = client.auth().waitForCompletion(authResponse.id().get())
+     * 
+ */ + fun waitForCompletion(authorizationResponseId: String): AuthorizationResponse } diff --git a/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthServiceImpl.kt b/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthServiceImpl.kt index b6cb246..9170dba 100644 --- a/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthServiceImpl.kt +++ b/arcade-java-core/src/main/kotlin/dev/arcade/services/blocking/AuthServiceImpl.kt @@ -18,12 +18,17 @@ import dev.arcade.core.prepare import dev.arcade.models.AuthorizationResponse import dev.arcade.models.auth.AuthAuthorizeParams import dev.arcade.models.auth.AuthConfirmUserParams +import dev.arcade.models.auth.AuthRequest import dev.arcade.models.auth.AuthStatusParams import dev.arcade.models.auth.ConfirmUserResponse import java.util.function.Consumer class AuthServiceImpl internal constructor(private val clientOptions: ClientOptions) : AuthService { + companion object { + const val DEFAULT_LONGPOLL_WAIT_TIME = 45L + } + private val withRawResponse: AuthService.WithRawResponse by lazy { WithRawResponseImpl(clientOptions) } @@ -150,4 +155,63 @@ class AuthServiceImpl internal constructor(private val clientOptions: ClientOpti } } } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + override fun start( + userId: String, + provider: String, + providerType: String, + scopes: List, + ): AuthorizationResponse { + return authorize( + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId(userId) + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId(provider) + .providerType(providerType) + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(scopes) + .build() + ) + .build() + ) + .build() + ) + .build() + ) + } + + override fun waitForCompletion( + authorizationResponse: AuthorizationResponse + ): AuthorizationResponse { + var response = authorizationResponse + while (AuthorizationResponse.Status.COMPLETED != response.status().get()) { + response = + status( + AuthStatusParams.builder() + .id(response.id().get()) + .wait(DEFAULT_LONGPOLL_WAIT_TIME) + .build() + ) + } + return response + } + + override fun waitForCompletion(authorizationResponseId: String): AuthorizationResponse { + return waitForCompletion( + status( + AuthStatusParams.builder() + .id(authorizationResponseId) + .wait(DEFAULT_LONGPOLL_WAIT_TIME) + .build() + ) + ) + } } diff --git a/arcade-java-core/src/test/kotlin/dev/arcade/services/async/AuthServiceAsyncTest.kt b/arcade-java-core/src/test/kotlin/dev/arcade/services/async/AuthServiceAsyncTest.kt index 9bed530..d41b7a6 100644 --- a/arcade-java-core/src/test/kotlin/dev/arcade/services/async/AuthServiceAsyncTest.kt +++ b/arcade-java-core/src/test/kotlin/dev/arcade/services/async/AuthServiceAsyncTest.kt @@ -3,12 +3,16 @@ package dev.arcade.services.async import dev.arcade.TestServerExtension +import dev.arcade.client.okhttp.ArcadeOkHttpClient import dev.arcade.client.okhttp.ArcadeOkHttpClientAsync +import dev.arcade.models.auth.AuthAuthorizeParams import dev.arcade.models.auth.AuthRequest import dev.arcade.models.auth.AuthStatusParams import dev.arcade.models.auth.ConfirmUserRequest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify @ExtendWith(TestServerExtension::class) internal class AuthServiceAsyncTest { @@ -79,4 +83,104 @@ internal class AuthServiceAsyncTest { val authorizationResponse = authorizationResponseFuture.get() authorizationResponse.validate() } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + @Test + fun start() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("provider_type") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(listOf("scope_one", "scope_two")) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> + auth.start("user_id", "provider_id", "provider_type", listOf("scope_one", "scope_two")) + } + } + + @Test + fun start_noScopes() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("provider_type") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(emptyList()) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> auth.start("user_id", "provider_id", "provider_type") } + } + + @Test + fun start_noProviderType() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("oauth2") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(emptyList()) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> auth.start("user_id", "provider_id") } + } + + private fun verifyAuthorize( + expected: AuthAuthorizeParams, + testCode: (AuthServiceAsync) -> Unit, + ) { + // given + val client = + ArcadeOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val auth = spy(client.async().auth()) + + // when + testCode.invoke(auth) + + // then + verify(auth).authorize(expected) + } } diff --git a/arcade-java-core/src/test/kotlin/dev/arcade/services/blocking/AuthServiceTest.kt b/arcade-java-core/src/test/kotlin/dev/arcade/services/blocking/AuthServiceTest.kt index 06f11c3..6d493ca 100644 --- a/arcade-java-core/src/test/kotlin/dev/arcade/services/blocking/AuthServiceTest.kt +++ b/arcade-java-core/src/test/kotlin/dev/arcade/services/blocking/AuthServiceTest.kt @@ -4,11 +4,21 @@ package dev.arcade.services.blocking import dev.arcade.TestServerExtension import dev.arcade.client.okhttp.ArcadeOkHttpClient +import dev.arcade.core.RequestOptions +import dev.arcade.models.AuthorizationResponse +import dev.arcade.models.auth.AuthAuthorizeParams import dev.arcade.models.auth.AuthRequest import dev.arcade.models.auth.AuthStatusParams import dev.arcade.models.auth.ConfirmUserRequest +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @ExtendWith(TestServerExtension::class) internal class AuthServiceTest { @@ -76,4 +86,201 @@ internal class AuthServiceTest { authorizationResponse.validate() } + + // ------------------------------------------------------------------------- + // Start of manually added code + // ------------------------------------------------------------------------- + + @Test + fun start() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("provider_type") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(listOf("scope_one", "scope_two")) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> + auth.start("user_id", "provider_id", "provider_type", listOf("scope_one", "scope_two")) + } + } + + @Test + fun start_noScopes() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("provider_type") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(emptyList()) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> auth.start("user_id", "provider_id", "provider_type") } + } + + @Test + fun start_noProviderType() { + val expected = + AuthAuthorizeParams.builder() + .authRequest( + AuthRequest.builder() + .userId("user_id") + .authRequirement( + AuthRequest.AuthRequirement.builder() + .providerId("provider_id") + .providerType("oauth2") + .oauth2( + AuthRequest.AuthRequirement.Oauth2.builder() + .scopes(emptyList()) + .build() + ) + .build() + ) + .build() + ) + .build() + + verifyAuthorize(expected) { auth -> auth.start("user_id", "provider_id") } + } + + @Test + fun waitForCompletion() { + // given + val client = + ArcadeOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val auth = spy(client.auth()) + + val authResponse = + AuthorizationResponse.builder() + .id("start_id") + .status(AuthorizationResponse.Status.NOT_STARTED) + .build() + + val secondAuthResponse = + AuthorizationResponse.builder() + .id("second_request_id") + .status(AuthorizationResponse.Status.PENDING) + .build() + + val completedAuthResponse = + AuthorizationResponse.builder() + .id("completed_id") + .status(AuthorizationResponse.Status.COMPLETED) + .build() + + val expectedAuthStatusParams = + AuthStatusParams.builder() + .id("start_id") + .wait(AuthServiceImpl.Companion.DEFAULT_LONGPOLL_WAIT_TIME) + .build() + + doReturn(secondAuthResponse) + .whenever(auth) + .status(argThat { id() == "start_id" }, eq(RequestOptions.none())) + + doReturn(completedAuthResponse) + .whenever(auth) + .status( + argThat { id() == "second_request_id" }, + eq(RequestOptions.none()), + ) + + // when + val result = auth.waitForCompletion(authResponse) + + // then + assertThat(result).isEqualTo(completedAuthResponse) + verify(auth).status(expectedAuthStatusParams) + } + + @Test + fun waitForCompletion_withId() { + // given + val client = + ArcadeOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val auth = spy(client.auth()) + + val completedAuthResponse = + AuthorizationResponse.builder() + .id("completed_id") + .status(AuthorizationResponse.Status.COMPLETED) + .build() + + val secondAuthResponse = + AuthorizationResponse.builder() + .id("second_request_id") + .status(AuthorizationResponse.Status.PENDING) + .build() + + val expectedAuthStatusParams = + AuthStatusParams.builder() + .id("start_id") + .wait(AuthServiceImpl.Companion.DEFAULT_LONGPOLL_WAIT_TIME) + .build() + + doReturn(secondAuthResponse) + .whenever(auth) + .status(argThat { id() == "start_id" }, eq(RequestOptions.none())) + + doReturn(completedAuthResponse) + .whenever(auth) + .status( + argThat { id() == "second_request_id" }, + eq(RequestOptions.none()), + ) + + // when + val result = auth.waitForCompletion("start_id") + + // then + assertThat(result).isEqualTo(completedAuthResponse) + verify(auth).status(expectedAuthStatusParams) + } + + private fun verifyAuthorize(expected: AuthAuthorizeParams, testCode: (AuthService) -> Unit) { + // given + val client = + ArcadeOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val auth = spy(client.auth()) + + // when + testCode.invoke(auth) + + // then + verify(auth).authorize(expected) + } } diff --git a/arcade-java-example/src/main/java/dev/arcade/example/AuthExample.java b/arcade-java-example/src/main/java/dev/arcade/example/AuthExample.java new file mode 100644 index 0000000..3511755 --- /dev/null +++ b/arcade-java-example/src/main/java/dev/arcade/example/AuthExample.java @@ -0,0 +1,38 @@ +package dev.arcade.example; + +import dev.arcade.client.ArcadeClient; +import dev.arcade.client.okhttp.ArcadeOkHttpClient; +import dev.arcade.models.AuthorizationResponse; +import java.util.List; + +public class AuthExample { + + /** + * + * @param args + */ + public static void main(String[] args) { + + // As the developer, you must identify the user you're authorizing + // and pass a unique identifier for them (e.g. an email or user ID) to Arcade: + String userId = System.getenv("ARCADE_USER_ID"); + if (userId == null) { + throw new IllegalArgumentException("Missing environment variable USER_ID"); + } + + ArcadeClient client = ArcadeOkHttpClient.builder().fromEnv().build(); + + // get the auth service, and call start + AuthorizationResponse authResponse = client.auth().start(userId, "github", "oauth2", List.of("repo")); + + // check the response status + authResponse + .status() + .filter(status -> status != AuthorizationResponse.Status.COMPLETED) + .ifPresent(status -> System.out.println( + "Click this link to authorize: " + authResponse.url().get())); + + // if the authorization is NOT complete, you can wait using the following method (for CLI applications): + client.auth().waitForCompletion(authResponse); + } +}