From 1acd16decb3d93d8ca7ccb84d8b415bb643b2b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Tue, 31 Mar 2026 08:52:56 +0200 Subject: [PATCH 1/2] feat(examples): oauth2 --- README.md | 10 + examples/oauth2/build.gradle | 13 + .../sumup/examples/oauth2/OAuth2Example.java | 267 ++++++++++++++++++ settings.gradle | 3 + 4 files changed, 293 insertions(+) create mode 100644 examples/oauth2/build.gradle create mode 100644 examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java diff --git a/README.md b/README.md index a764fb5..6aa178d 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 ScribeJava 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..e1701b5 --- /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.github.scribejava:scribejava-core:8.3.3' +} 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..a2171ed --- /dev/null +++ b/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java @@ -0,0 +1,267 @@ +package com.sumup.examples.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.github.scribejava.core.oauth.AccessTokenRequestParams; +import com.github.scribejava.core.oauth.AuthorizationUrlBuilder; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.github.scribejava.core.pkce.PKCE; +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.security.SecureRandom; +import java.util.Base64; +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 ScribeJava 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 String SCOPES = "email profile"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + 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"; + } + + OAuth20Service oauthService = + new ServiceBuilder(clientId) + .apiSecret(clientSecret) + .defaultScope(SCOPES) + .callback(redirectUri) + .build(new SumUpOAuthApi()); + + 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; + } + + String state = randomUrlSafeString(32); + AuthorizationUrlBuilder authorizationUrlBuilder = + oauthService.createAuthorizationUrlBuilder().state(state).initPKCE(); + PKCE pkce = authorizationUrlBuilder.getPkce(); + + exchange.getResponseHeaders() + .add("Set-Cookie", buildCookie(STATE_COOKIE_NAME, state)); + exchange.getResponseHeaders() + .add("Set-Cookie", buildCookie(PKCE_COOKIE_NAME, pkce.getCodeVerifier())); + + String authorizationUrl = authorizationUrlBuilder.build(); + + exchange.getResponseHeaders().add("Location", authorizationUrl); + 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, oauthService); + } catch (Exception ex) { + sendText(exchange, 500, "OAuth2 error: " + ex.getMessage()); + } + }); + + server.createContext( + "/", + exchange -> { + String body = + """ + + +

SumUp OAuth2 Example

+

This example uses ScribeJava for the OAuth2 Authorization Code flow with PKCE.

+

Start OAuth2 Flow

+ + + """; + 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, OAuth20Service oauthService) + throws Exception { + Map queryParams = parseQuery(exchange.getRequestURI().getRawQuery()); + String expectedState = readCookie(exchange, STATE_COOKIE_NAME); + String codeVerifier = readCookie(exchange, PKCE_COOKIE_NAME); + + if (expectedState == null || codeVerifier == null) { + sendText(exchange, 400, "Missing OAuth cookies"); + return; + } + + String state = queryParams.get("state"); + if (state == null || !state.equals(expectedState)) { + sendText(exchange, 400, "Invalid OAuth state"); + return; + } + + String code = queryParams.get("code"); + if (code == null || code.isBlank()) { + sendText(exchange, 400, "Missing authorization code"); + return; + } + + String merchantCode = queryParams.get("merchant_code"); + if (merchantCode == null || merchantCode.isBlank()) { + sendText(exchange, 400, "Missing merchant_code query parameter"); + return; + } + + OAuth2AccessToken accessToken = + oauthService.getAccessToken( + AccessTokenRequestParams.create(code).pkceCodeVerifier(codeVerifier)); + SumUpClient client = new SumUpClient(accessToken.getAccessToken()); + + try { + Merchant merchant = client.merchants().getMerchant(merchantCode); + String body = + "
"
+              + 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 String randomUrlSafeString(int byteCount) { + byte[] bytes = new byte[byteCount]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static Map parseQuery(String rawQuery) { + Map params = new HashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) { + return params; + } + + for (String pair : rawQuery.split("&")) { + int index = pair.indexOf('='); + String key = index >= 0 ? decode(pair.substring(0, index)) : decode(pair); + String value = index >= 0 ? decode(pair.substring(index + 1)) : ""; + params.put(key, value); + } + return params; + } + + private static String decode(String value) { + return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String buildCookie(String name, String value) { + return new StringJoiner("; ") + .add(name + "=" + value) + .add("Path=/") + .add("HttpOnly") + .add("SameSite=Lax") + .toString(); + } + + private static String readCookie(HttpExchange exchange, String cookieName) { + List cookieHeaders = exchange.getRequestHeaders().get("Cookie"); + if (cookieHeaders == null) { + return null; + } + + for (String header : cookieHeaders) { + for (String cookie : header.split(";")) { + String trimmed = cookie.trim(); + if (trimmed.startsWith(cookieName + "=")) { + return trimmed.substring(cookieName.length() + 1); + } + } + } + return null; + } + + private static void sendText(HttpExchange exchange, int statusCode, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + } + + private static void sendHtml(HttpExchange exchange, int statusCode, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + } + + private static String escapeHtml(String input) { + return input.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " environment variable must be set"); + } + return value; + } + + private static final class SumUpOAuthApi extends DefaultApi20 { + @Override + public String getAccessTokenEndpoint() { + return "https://api.sumup.com/token"; + } + + @Override + protected String getAuthorizationBaseUrl() { + return "https://api.sumup.com/authorize"; + } + } +} diff --git a/settings.gradle b/settings.gradle index 79e37fa..ea2e5e2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,6 @@ project(':examples:basic').projectDir = file('examples/basic') include(':examples:card-reader-checkout') project(':examples:card-reader-checkout').projectDir = file('examples/card-reader-checkout') + +include(':examples:oauth2') +project(':examples:oauth2').projectDir = file('examples/oauth2') From cff4f7e1832fa83f8de90fe6a35888b001ebea48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Tue, 31 Mar 2026 22:06:08 +0200 Subject: [PATCH 2/2] chore: use nimbus --- README.md | 2 +- examples/oauth2/build.gradle | 2 +- .../sumup/examples/oauth2/OAuth2Example.java | 136 ++++++++++-------- 3 files changed, 81 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 6aa178d..859940a 100644 --- a/README.md +++ b/README.md @@ -260,7 +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 ScribeJava 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`. +- `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: diff --git a/examples/oauth2/build.gradle b/examples/oauth2/build.gradle index e1701b5..4e769fb 100644 --- a/examples/oauth2/build.gradle +++ b/examples/oauth2/build.gradle @@ -9,5 +9,5 @@ application { dependencies { implementation project(':sumup-sdk') - implementation 'com.github.scribejava:scribejava-core:8.3.3' + 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 index a2171ed..ea8b4f4 100644 --- a/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java +++ b/examples/oauth2/src/main/java/com/sumup/examples/oauth2/OAuth2Example.java @@ -1,13 +1,24 @@ package com.sumup.examples.oauth2; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.scribejava.core.builder.ServiceBuilder; -import com.github.scribejava.core.builder.api.DefaultApi20; -import com.github.scribejava.core.oauth.AccessTokenRequestParams; -import com.github.scribejava.core.oauth.AuthorizationUrlBuilder; -import com.github.scribejava.core.model.OAuth2AccessToken; -import com.github.scribejava.core.oauth.OAuth20Service; -import com.github.scribejava.core.pkce.PKCE; +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; @@ -18,8 +29,6 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,16 +37,17 @@ /** * OAuth 2.0 Authorization Code flow with SumUp. * - *

This example uses ScribeJava 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}. + *

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 String SCOPES = "email profile"; + 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 static final SecureRandom SECURE_RANDOM = new SecureRandom(); private OAuth2Example() {} @@ -52,13 +62,6 @@ public static void main(String[] args) throws IOException { callbackPath = "/callback"; } - OAuth20Service oauthService = - new ServiceBuilder(clientId) - .apiSecret(clientSecret) - .defaultScope(SCOPES) - .callback(redirectUri) - .build(new SumUpOAuthApi()); - int listenPort = redirect.getPort() == -1 ? 8080 : redirect.getPort(); HttpServer server = HttpServer.create(new InetSocketAddress(listenPort), 0); @@ -70,19 +73,26 @@ public static void main(String[] args) throws IOException { return; } - String state = randomUrlSafeString(32); - AuthorizationUrlBuilder authorizationUrlBuilder = - oauthService.createAuthorizationUrlBuilder().state(state).initPKCE(); - PKCE pkce = authorizationUrlBuilder.getPkce(); - - exchange.getResponseHeaders() - .add("Set-Cookie", buildCookie(STATE_COOKIE_NAME, state)); - exchange.getResponseHeaders() - .add("Set-Cookie", buildCookie(PKCE_COOKIE_NAME, pkce.getCodeVerifier())); - - String authorizationUrl = authorizationUrlBuilder.build(); - - exchange.getResponseHeaders().add("Location", authorizationUrl); + 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(); }); @@ -96,7 +106,7 @@ public static void main(String[] args) throws IOException { } try { - handleCallback(exchange, oauthService); + handleCallback(exchange, clientId, clientSecret, redirect); } catch (Exception ex) { sendText(exchange, 500, "OAuth2 error: " + ex.getMessage()); } @@ -110,7 +120,7 @@ public static void main(String[] args) throws IOException {

SumUp OAuth2 Example

-

This example uses ScribeJava for the OAuth2 Authorization Code flow with PKCE.

+

This example uses Nimbus OAuth 2.0 SDK for the OAuth2 Authorization Code flow with PKCE.

Start OAuth2 Flow

@@ -123,7 +133,8 @@ public static void main(String[] args) throws IOException { System.out.printf("Server is running at %s%n", redirectUri); } - private static void handleCallback(HttpExchange exchange, OAuth20Service oauthService) + private static void handleCallback( + HttpExchange exchange, String clientId, String clientSecret, URI redirectUri) throws Exception { Map queryParams = parseQuery(exchange.getRequestURI().getRawQuery()); String expectedState = readCookie(exchange, STATE_COOKIE_NAME); @@ -152,10 +163,10 @@ private static void handleCallback(HttpExchange exchange, OAuth20Service oauthSe return; } - OAuth2AccessToken accessToken = - oauthService.getAccessToken( - AccessTokenRequestParams.create(code).pkceCodeVerifier(codeVerifier)); - SumUpClient client = new SumUpClient(accessToken.getAccessToken()); + AccessTokenResponse accessTokenResponse = + exchangeAccessToken(clientId, clientSecret, redirectUri, code, codeVerifier); + SumUpClient client = + new SumUpClient(accessTokenResponse.getTokens().getAccessToken().getValue()); try { Merchant merchant = client.merchants().getMerchant(merchantCode); @@ -170,10 +181,33 @@ private static void handleCallback(HttpExchange exchange, OAuth20Service oauthSe } } - private static String randomUrlSafeString(int byteCount) { - byte[] bytes = new byte[byteCount]; - SECURE_RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + 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 parseQuery(String rawQuery) { @@ -252,16 +286,4 @@ private static String requireEnv(String name) { } return value; } - - private static final class SumUpOAuthApi extends DefaultApi20 { - @Override - public String getAccessTokenEndpoint() { - return "https://api.sumup.com/token"; - } - - @Override - protected String getAuthorizationBaseUrl() { - return "https://api.sumup.com/authorize"; - } - } }