diff --git a/README.md b/README.md index a764fb5..859940a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ readerIdFuture - `examples/basic` – lists recent checkouts to verify that your API token works. - `examples/card-reader-checkout` – lists paired readers and creates a €10 checkout on the first available device. +- `examples/oauth2` – uses Nimbus OAuth 2.0 SDK to run a local OAuth 2.0 Authorization Code flow with PKCE, exchanges the callback code for an access token, and fetches merchant information using the returned `merchant_code`. To run the card reader example locally: @@ -271,6 +272,15 @@ export SUMUP_MERCHANT_CODE="your_merchant_code" ./gradlew :examples:card-reader-checkout:run ``` +To run the OAuth2 example locally: + +```bash +export CLIENT_ID="your_client_id" +export CLIENT_SECRET="your_client_secret" +export REDIRECT_URI="http://localhost:8080/callback" +./gradlew :examples:oauth2:run +``` + ## Generating Javadoc Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. diff --git a/examples/oauth2/build.gradle b/examples/oauth2/build.gradle new file mode 100644 index 0000000..4e769fb --- /dev/null +++ b/examples/oauth2/build.gradle @@ -0,0 +1,13 @@ +plugins { + id 'application' + id 'com.diffplug.spotless' +} + +application { + mainClass = 'com.sumup.examples.oauth2.OAuth2Example' +} + +dependencies { + implementation project(':sumup-sdk') + implementation 'com.nimbusds:oauth2-oidc-sdk:11.34' +} diff --git a/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java b/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java new file mode 100644 index 0000000..ea8b4f4 --- /dev/null +++ b/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java @@ -0,0 +1,289 @@ +package com.sumup.examples.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.oauth2.sdk.AccessTokenResponse; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.AuthorizationRequest; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.ResponseType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.TokenErrorResponse; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.TokenResponse; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.http.HTTPRequest; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod; +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier; +import com.sumup.sdk.SumUpClient; +import com.sumup.sdk.core.ApiException; +import com.sumup.sdk.models.Merchant; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; + +/** + * OAuth 2.0 Authorization Code flow with SumUp. + * + *
This example uses Nimbus OAuth 2.0 SDK to handle the OAuth2 Authorization Code flow with PKCE. + * Set {@code CLIENT_ID}, {@code CLIENT_SECRET}, and {@code REDIRECT_URI}, then run {@code ./gradlew + * :examples:oauth2:run}. + */ +public final class OAuth2Example { + private static final String STATE_COOKIE_NAME = "oauth_state"; + private static final String PKCE_COOKIE_NAME = "oauth_pkce"; + private static final Scope SCOPES = new Scope("email", "profile"); + private static final URI AUTHORIZATION_ENDPOINT = URI.create("https://api.sumup.com/authorize"); + private static final URI TOKEN_ENDPOINT = URI.create("https://api.sumup.com/token"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private OAuth2Example() {} + + public static void main(String[] args) throws IOException { + String clientId = requireEnv("CLIENT_ID"); + String clientSecret = requireEnv("CLIENT_SECRET"); + String redirectUri = requireEnv("REDIRECT_URI"); + + URI redirect = URI.create(redirectUri); + String callbackPath = redirect.getPath(); + if (callbackPath == null || callbackPath.isBlank()) { + callbackPath = "/callback"; + } + + int listenPort = redirect.getPort() == -1 ? 8080 : redirect.getPort(); + HttpServer server = HttpServer.create(new InetSocketAddress(listenPort), 0); + + server.createContext( + "/login", + exchange -> { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendText(exchange, 405, "Method Not Allowed"); + return; + } + + State state = new State(); + CodeVerifier codeVerifier = new CodeVerifier(); + AuthorizationRequest authorizationRequest = + new AuthorizationRequest.Builder( + new ResponseType(ResponseType.Value.CODE), new ClientID(clientId)) + .endpointURI(AUTHORIZATION_ENDPOINT) + .redirectionURI(redirect) + .scope(SCOPES) + .state(state) + .codeChallenge(codeVerifier, CodeChallengeMethod.S256) + .build(); + + exchange + .getResponseHeaders() + .add("Set-Cookie", buildCookie(STATE_COOKIE_NAME, state.getValue())); + exchange + .getResponseHeaders() + .add("Set-Cookie", buildCookie(PKCE_COOKIE_NAME, codeVerifier.getValue())); + + exchange.getResponseHeaders().add("Location", authorizationRequest.toURI().toString()); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + + server.createContext( + callbackPath, + exchange -> { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendText(exchange, 405, "Method Not Allowed"); + return; + } + + try { + handleCallback(exchange, clientId, clientSecret, redirect); + } catch (Exception ex) { + sendText(exchange, 500, "OAuth2 error: " + ex.getMessage()); + } + }); + + server.createContext( + "/", + exchange -> { + String body = + """ + +
+This example uses Nimbus OAuth 2.0 SDK for the OAuth2 Authorization Code flow with PKCE.
+ + + + """; + sendHtml(exchange, 200, body); + }); + + server.setExecutor(null); + server.start(); + System.out.printf("Server is running at %s%n", redirectUri); + } + + private static void handleCallback( + HttpExchange exchange, String clientId, String clientSecret, URI redirectUri) + throws Exception { + Map" + + escapeHtml( + OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(merchant)) + + ""; + sendHtml(exchange, 200, body); + } catch (ApiException ex) { + sendText(exchange, ex.getStatusCode(), "Failed to fetch merchant: " + ex.getResponseBody()); + } + } + + private static AccessTokenResponse exchangeAccessToken( + String clientId, String clientSecret, URI redirectUri, String code, String codeVerifier) + throws IOException, ParseException { + AuthorizationCodeGrant codeGrant = + new AuthorizationCodeGrant( + new AuthorizationCode(code), redirectUri, new CodeVerifier(codeVerifier)); + TokenRequest tokenRequest = + new TokenRequest( + TOKEN_ENDPOINT, + new ClientSecretBasic(new ClientID(clientId), new Secret(clientSecret)), + codeGrant, + null); + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + HTTPResponse httpResponse = httpRequest.send(); + TokenResponse tokenResponse = TokenResponse.parse(httpResponse); + + if (!tokenResponse.indicatesSuccess()) { + TokenErrorResponse errorResponse = tokenResponse.toErrorResponse(); + String description = errorResponse.getErrorObject().getDescription(); + String message = + description == null || description.isBlank() + ? errorResponse.getErrorObject().getCode() + : errorResponse.getErrorObject().getCode() + ": " + description; + throw new IOException("Failed to exchange authorization code: " + message); + } + + return tokenResponse.toSuccessResponse(); + } + + private static Map