|
| 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("&", "&").replace("<", "<").replace(">", ">"); |
| 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 | +} |
0 commit comments