Skip to content

Commit 1acd16d

Browse files
committed
feat(examples): oauth2
1 parent cecd07d commit 1acd16d

4 files changed

Lines changed: 293 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ readerIdFuture
260260

261261
- `examples/basic` – lists recent checkouts to verify that your API token works.
262262
- `examples/card-reader-checkout` – lists paired readers and creates a €10 checkout on the first available device.
263+
- `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`.
263264

264265
To run the card reader example locally:
265266

@@ -271,6 +272,15 @@ export SUMUP_MERCHANT_CODE="your_merchant_code"
271272
./gradlew :examples:card-reader-checkout:run
272273
```
273274

275+
To run the OAuth2 example locally:
276+
277+
```bash
278+
export CLIENT_ID="your_client_id"
279+
export CLIENT_SECRET="your_client_secret"
280+
export REDIRECT_URI="http://localhost:8080/callback"
281+
./gradlew :examples:oauth2:run
282+
```
283+
274284
## Generating Javadoc
275285

276286
Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`.

examples/oauth2/build.gradle

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
plugins {
2+
id 'application'
3+
id 'com.diffplug.spotless'
4+
}
5+
6+
application {
7+
mainClass = 'com.sumup.examples.oauth2.OAuth2Example'
8+
}
9+
10+
dependencies {
11+
implementation project(':sumup-sdk')
12+
implementation 'com.github.scribejava:scribejava-core:8.3.3'
13+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
package com.sumup.examples.oauth2;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.github.scribejava.core.builder.ServiceBuilder;
5+
import com.github.scribejava.core.builder.api.DefaultApi20;
6+
import com.github.scribejava.core.oauth.AccessTokenRequestParams;
7+
import com.github.scribejava.core.oauth.AuthorizationUrlBuilder;
8+
import com.github.scribejava.core.model.OAuth2AccessToken;
9+
import com.github.scribejava.core.oauth.OAuth20Service;
10+
import com.github.scribejava.core.pkce.PKCE;
11+
import com.sumup.sdk.SumUpClient;
12+
import com.sumup.sdk.core.ApiException;
13+
import com.sumup.sdk.models.Merchant;
14+
import com.sun.net.httpserver.HttpExchange;
15+
import com.sun.net.httpserver.HttpServer;
16+
import java.io.IOException;
17+
import java.io.OutputStream;
18+
import java.net.InetSocketAddress;
19+
import java.net.URI;
20+
import java.nio.charset.StandardCharsets;
21+
import java.security.SecureRandom;
22+
import java.util.Base64;
23+
import java.util.HashMap;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.StringJoiner;
27+
28+
/**
29+
* OAuth 2.0 Authorization Code flow with SumUp.
30+
*
31+
* <p>This example uses ScribeJava to handle the OAuth2 Authorization Code flow with PKCE. Set
32+
* {@code CLIENT_ID}, {@code CLIENT_SECRET}, and {@code REDIRECT_URI}, then run
33+
* {@code ./gradlew :examples:oauth2:run}.
34+
*/
35+
public final class OAuth2Example {
36+
private static final String STATE_COOKIE_NAME = "oauth_state";
37+
private static final String PKCE_COOKIE_NAME = "oauth_pkce";
38+
private static final String SCOPES = "email profile";
39+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
40+
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
41+
42+
private OAuth2Example() {}
43+
44+
public static void main(String[] args) throws IOException {
45+
String clientId = requireEnv("CLIENT_ID");
46+
String clientSecret = requireEnv("CLIENT_SECRET");
47+
String redirectUri = requireEnv("REDIRECT_URI");
48+
49+
URI redirect = URI.create(redirectUri);
50+
String callbackPath = redirect.getPath();
51+
if (callbackPath == null || callbackPath.isBlank()) {
52+
callbackPath = "/callback";
53+
}
54+
55+
OAuth20Service oauthService =
56+
new ServiceBuilder(clientId)
57+
.apiSecret(clientSecret)
58+
.defaultScope(SCOPES)
59+
.callback(redirectUri)
60+
.build(new SumUpOAuthApi());
61+
62+
int listenPort = redirect.getPort() == -1 ? 8080 : redirect.getPort();
63+
HttpServer server = HttpServer.create(new InetSocketAddress(listenPort), 0);
64+
65+
server.createContext(
66+
"/login",
67+
exchange -> {
68+
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
69+
sendText(exchange, 405, "Method Not Allowed");
70+
return;
71+
}
72+
73+
String state = randomUrlSafeString(32);
74+
AuthorizationUrlBuilder authorizationUrlBuilder =
75+
oauthService.createAuthorizationUrlBuilder().state(state).initPKCE();
76+
PKCE pkce = authorizationUrlBuilder.getPkce();
77+
78+
exchange.getResponseHeaders()
79+
.add("Set-Cookie", buildCookie(STATE_COOKIE_NAME, state));
80+
exchange.getResponseHeaders()
81+
.add("Set-Cookie", buildCookie(PKCE_COOKIE_NAME, pkce.getCodeVerifier()));
82+
83+
String authorizationUrl = authorizationUrlBuilder.build();
84+
85+
exchange.getResponseHeaders().add("Location", authorizationUrl);
86+
exchange.sendResponseHeaders(302, -1);
87+
exchange.close();
88+
});
89+
90+
server.createContext(
91+
callbackPath,
92+
exchange -> {
93+
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
94+
sendText(exchange, 405, "Method Not Allowed");
95+
return;
96+
}
97+
98+
try {
99+
handleCallback(exchange, oauthService);
100+
} catch (Exception ex) {
101+
sendText(exchange, 500, "OAuth2 error: " + ex.getMessage());
102+
}
103+
});
104+
105+
server.createContext(
106+
"/",
107+
exchange -> {
108+
String body =
109+
"""
110+
<html>
111+
<body>
112+
<h1>SumUp OAuth2 Example</h1>
113+
<p>This example uses ScribeJava for the OAuth2 Authorization Code flow with PKCE.</p>
114+
<p><a href="/login">Start OAuth2 Flow</a></p>
115+
</body>
116+
</html>
117+
""";
118+
sendHtml(exchange, 200, body);
119+
});
120+
121+
server.setExecutor(null);
122+
server.start();
123+
System.out.printf("Server is running at %s%n", redirectUri);
124+
}
125+
126+
private static void handleCallback(HttpExchange exchange, OAuth20Service oauthService)
127+
throws Exception {
128+
Map<String, String> queryParams = parseQuery(exchange.getRequestURI().getRawQuery());
129+
String expectedState = readCookie(exchange, STATE_COOKIE_NAME);
130+
String codeVerifier = readCookie(exchange, PKCE_COOKIE_NAME);
131+
132+
if (expectedState == null || codeVerifier == null) {
133+
sendText(exchange, 400, "Missing OAuth cookies");
134+
return;
135+
}
136+
137+
String state = queryParams.get("state");
138+
if (state == null || !state.equals(expectedState)) {
139+
sendText(exchange, 400, "Invalid OAuth state");
140+
return;
141+
}
142+
143+
String code = queryParams.get("code");
144+
if (code == null || code.isBlank()) {
145+
sendText(exchange, 400, "Missing authorization code");
146+
return;
147+
}
148+
149+
String merchantCode = queryParams.get("merchant_code");
150+
if (merchantCode == null || merchantCode.isBlank()) {
151+
sendText(exchange, 400, "Missing merchant_code query parameter");
152+
return;
153+
}
154+
155+
OAuth2AccessToken accessToken =
156+
oauthService.getAccessToken(
157+
AccessTokenRequestParams.create(code).pkceCodeVerifier(codeVerifier));
158+
SumUpClient client = new SumUpClient(accessToken.getAccessToken());
159+
160+
try {
161+
Merchant merchant = client.merchants().getMerchant(merchantCode);
162+
String body =
163+
"<pre>"
164+
+ escapeHtml(
165+
OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(merchant))
166+
+ "</pre>";
167+
sendHtml(exchange, 200, body);
168+
} catch (ApiException ex) {
169+
sendText(exchange, ex.getStatusCode(), "Failed to fetch merchant: " + ex.getResponseBody());
170+
}
171+
}
172+
173+
private static String randomUrlSafeString(int byteCount) {
174+
byte[] bytes = new byte[byteCount];
175+
SECURE_RANDOM.nextBytes(bytes);
176+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
177+
}
178+
179+
private static Map<String, String> parseQuery(String rawQuery) {
180+
Map<String, String> params = new HashMap<>();
181+
if (rawQuery == null || rawQuery.isBlank()) {
182+
return params;
183+
}
184+
185+
for (String pair : rawQuery.split("&")) {
186+
int index = pair.indexOf('=');
187+
String key = index >= 0 ? decode(pair.substring(0, index)) : decode(pair);
188+
String value = index >= 0 ? decode(pair.substring(index + 1)) : "";
189+
params.put(key, value);
190+
}
191+
return params;
192+
}
193+
194+
private static String decode(String value) {
195+
return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8);
196+
}
197+
198+
private static String buildCookie(String name, String value) {
199+
return new StringJoiner("; ")
200+
.add(name + "=" + value)
201+
.add("Path=/")
202+
.add("HttpOnly")
203+
.add("SameSite=Lax")
204+
.toString();
205+
}
206+
207+
private static String readCookie(HttpExchange exchange, String cookieName) {
208+
List<String> cookieHeaders = exchange.getRequestHeaders().get("Cookie");
209+
if (cookieHeaders == null) {
210+
return null;
211+
}
212+
213+
for (String header : cookieHeaders) {
214+
for (String cookie : header.split(";")) {
215+
String trimmed = cookie.trim();
216+
if (trimmed.startsWith(cookieName + "=")) {
217+
return trimmed.substring(cookieName.length() + 1);
218+
}
219+
}
220+
}
221+
return null;
222+
}
223+
224+
private static void sendText(HttpExchange exchange, int statusCode, String body)
225+
throws IOException {
226+
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
227+
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
228+
exchange.sendResponseHeaders(statusCode, bytes.length);
229+
try (OutputStream outputStream = exchange.getResponseBody()) {
230+
outputStream.write(bytes);
231+
}
232+
}
233+
234+
private static void sendHtml(HttpExchange exchange, int statusCode, String body)
235+
throws IOException {
236+
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
237+
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
238+
exchange.sendResponseHeaders(statusCode, bytes.length);
239+
try (OutputStream outputStream = exchange.getResponseBody()) {
240+
outputStream.write(bytes);
241+
}
242+
}
243+
244+
private static String escapeHtml(String input) {
245+
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
246+
}
247+
248+
private static String requireEnv(String name) {
249+
String value = System.getenv(name);
250+
if (value == null || value.isBlank()) {
251+
throw new IllegalStateException(name + " environment variable must be set");
252+
}
253+
return value;
254+
}
255+
256+
private static final class SumUpOAuthApi extends DefaultApi20 {
257+
@Override
258+
public String getAccessTokenEndpoint() {
259+
return "https://api.sumup.com/token";
260+
}
261+
262+
@Override
263+
protected String getAuthorizationBaseUrl() {
264+
return "https://api.sumup.com/authorize";
265+
}
266+
}
267+
}

settings.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ project(':examples:basic').projectDir = file('examples/basic')
88

99
include(':examples:card-reader-checkout')
1010
project(':examples:card-reader-checkout').projectDir = file('examples/card-reader-checkout')
11+
12+
include(':examples:oauth2')
13+
project(':examples:oauth2').projectDir = file('examples/oauth2')

0 commit comments

Comments
 (0)