package io.vertx.ext.auth.oauth2.impl;
import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.*;
import io.vertx.core.json.JsonObject;
import io.vertx.core.impl.logging.Logger;
import io.vertx.core.impl.logging.LoggerFactory;
import io.vertx.ext.auth.impl.http.SimpleHttpClient;
import io.vertx.ext.auth.impl.jose.JWT;
import io.vertx.ext.auth.oauth2.OAuth2FlowType;
import io.vertx.ext.auth.oauth2.OAuth2Options;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class OAuth2API {
private static final Logger LOG = LoggerFactory.getLogger(OAuth2API.class);
private static final Pattern MAX_AGE = Pattern.compile("max-age=\"?(\\d+)\"?");
private final HttpClient client;
private final OAuth2Options config;
public OAuth2API(Vertx vertx, OAuth2Options config) {
this.config = config;
this.client = vertx.createHttpClient(config.getHttpClientOptions());
}
public void jwkSet(Handler<AsyncResult<JsonObject>> handler) {
final JsonObject headers = new JsonObject();
headers.put("Accept", "application/jwk-set+json, application/json");
fetch(
HttpMethod.GET,
config.getJwkPath(),
headers,
null,
res -> {
if (res.failed()) {
handler.handle(Future.failedFuture(res.cause()));
return;
}
final OAuth2Response reply = res.result();
if (reply.body() == null || reply.body().length() == 0) {
handler.handle(Future.failedFuture("No Body"));
return;
}
JsonObject json;
if (reply.is("application/jwk-set+json") || reply.is("application/json")) {
try {
json = new JsonObject(reply.body());
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else {
handler.handle(Future.failedFuture("Cannot handle content type: " + reply.headers().get("Content-Type")));
return;
}
try {
if (json.containsKey("error")) {
handler.handle(Future.failedFuture(extractErrorDescription(json)));
} else {
List<String> cacheControl = reply.headers().getAll(HttpHeaders.CACHE_CONTROL);
if (cacheControl != null) {
for (String header : cacheControl) {
if (header.length() > 8) {
Matcher match = MAX_AGE.matcher(header);
if (match.find()) {
try {
json.put("maxAge", Long.valueOf(match.group(1)));
break;
} catch (RuntimeException e) {
}
}
}
}
}
handler.handle(Future.succeededFuture(json));
}
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
}
});
}
public String authorizeURL(JsonObject params) {
final JsonObject query = params.copy();
if (config.getFlow() != OAuth2FlowType.AUTH_CODE) {
throw new IllegalStateException("authorization URL cannot be computed for non AUTH_CODE flow");
}
if (query.containsKey("scopes")) {
query.put("scope", String.join(config.getScopeSeparator(), query.getJsonArray("scopes").getList()));
query.remove("scopes");
}
query.put("response_type", "code");
query.put("client_id", config.getClientID());
final String path = config.getAuthorizationPath();
final String url = path.charAt(0) == '/' ? config.getSite() + path : path;
return url + '?' + SimpleHttpClient.jsonToQuery(query).toString();
}
public void token(String grantType, JsonObject params, Handler<AsyncResult<JsonObject>> handler) {
if (grantType == null) {
handler.handle(Future.failedFuture("Token request requires a grantType other than null"));
return;
}
final JsonObject headers = new JsonObject();
final boolean confidentialClient = config.getClientID() != null && config.getClientSecret() != null;
if (confidentialClient) {
String basic = config.getClientID() + ":" + config.getClientSecret();
headers.put("Authorization", "Basic " + Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
}
JsonObject tmp = config.getHeaders();
if (tmp != null) {
headers.mergeIn(tmp);
}
final JsonObject form = params.copy();
if (config.getExtraParameters() != null) {
form.mergeIn(config.getExtraParameters());
}
form.put("grant_type", grantType);
if (!confidentialClient) {
form.put("client_id", config.getClientID());
}
headers.put("Content-Type", "application/x-www-form-urlencoded");
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
fetch(
HttpMethod.POST,
config.getTokenPath(),
headers,
payload,
res -> {
if (res.failed()) {
handler.handle(Future.failedFuture(res.cause()));
return;
}
final OAuth2Response reply = res.result();
if (reply.body() == null || reply.body().length() == 0) {
handler.handle(Future.failedFuture("No Body"));
return;
}
JsonObject json;
if (reply.is("application/json")) {
try {
json = new JsonObject(reply.body());
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
json = SimpleHttpClient.queryToJson(reply.body());
} catch (UnsupportedEncodingException | RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else {
handler.handle(Future.failedFuture("Cannot handle content type: " + reply.headers().get("Content-Type")));
return;
}
try {
if (json.containsKey("error")) {
handler.handle(Future.failedFuture(extractErrorDescription(json)));
} else {
OAuth2API.processNonStandardHeaders(json, reply, config.getScopeSeparator());
handler.handle(Future.succeededFuture(json));
}
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
}
});
}
public void tokenIntrospection(String tokenType, String token, Handler<AsyncResult<JsonObject>> handler) {
final JsonObject headers = new JsonObject();
final boolean confidentialClient = config.getClientID() != null && config.getClientSecret() != null;
if (confidentialClient) {
String basic = config.getClientID() + ":" + config.getClientSecret();
headers.put("Authorization", "Basic " + Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
}
JsonObject tmp = config.getHeaders();
if (tmp != null) {
headers.mergeIn(tmp);
}
final JsonObject form = new JsonObject()
.put("token", token)
.put("token_type_hint", tokenType);
headers.put("Content-Type", "application/x-www-form-urlencoded");
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
fetch(
HttpMethod.POST,
config.getIntrospectionPath(),
headers,
payload,
res -> {
if (res.failed()) {
handler.handle(Future.failedFuture(res.cause()));
return;
}
final OAuth2Response reply = res.result();
if (reply.body() == null || reply.body().length() == 0) {
handler.handle(Future.failedFuture("No Body"));
return;
}
JsonObject json;
if (reply.is("application/json")) {
try {
json = new JsonObject(reply.body());
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
json = SimpleHttpClient.queryToJson(reply.body());
} catch (UnsupportedEncodingException | RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else {
handler.handle(Future.failedFuture("Cannot handle accessToken type: " + reply.headers().get("Content-Type")));
return;
}
try {
if (json.containsKey("error")) {
handler.handle(Future.failedFuture(extractErrorDescription(json)));
} else {
processNonStandardHeaders(json, reply, config.getScopeSeparator());
handler.handle(Future.succeededFuture(json));
}
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
}
});
}
public void tokenRevocation(String tokenType, String token, Handler<AsyncResult<Void>> handler) {
if (token == null) {
handler.handle(Future.failedFuture("Cannot revoke null token"));
return;
}
final JsonObject headers = new JsonObject();
final boolean confidentialClient = config.getClientID() != null && config.getClientSecret() != null;
if (confidentialClient) {
String basic = config.getClientID() + ":" + config.getClientSecret();
headers.put("Authorization", "Basic " + Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
}
final JsonObject tmp = config.getHeaders();
if (tmp != null) {
headers.mergeIn(tmp);
}
final JsonObject form = new JsonObject();
form
.put("token", token)
.put("token_type_hint", tokenType);
headers.put("Content-Type", "application/x-www-form-urlencoded");
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
fetch(
HttpMethod.POST,
config.getRevocationPath(),
headers,
payload,
res -> {
if (res.failed()) {
handler.handle(Future.failedFuture(res.cause()));
return;
}
final OAuth2Response reply = res.result();
if (reply.body() == null) {
handler.handle(Future.failedFuture("No Body"));
return;
}
handler.handle(Future.succeededFuture());
});
}
public void userInfo(String accessToken, JWT jwt, Handler<AsyncResult<JsonObject>> handler) {
final JsonObject headers = new JsonObject();
final JsonObject extraParams = config.getUserInfoParameters();
String path = config.getUserInfoPath();
if (path == null) {
handler.handle(Future.failedFuture("userInfo path is not configured"));
return;
}
if (extraParams != null) {
path += "?" + SimpleHttpClient.jsonToQuery(extraParams).toString();
}
headers.put("Authorization", "Bearer " + accessToken);
headers.put("Accept", "application/json,application/jwt,application/x-www-form-urlencoded;q=0.9");
fetch(
HttpMethod.GET,
path,
headers,
null,
fetch -> {
if (fetch.failed()) {
handler.handle(Future.failedFuture(fetch.cause()));
return;
}
final OAuth2Response reply = fetch.result();
JsonObject userInfo;
if (reply.is("application/json")) {
try {
userInfo = new JsonObject(reply.body());
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else if (reply.is("application/jwt")) {
try {
userInfo = jwt.decode(reply.body().toString(StandardCharsets.UTF_8));
} catch (RuntimeException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
userInfo = SimpleHttpClient.queryToJson(reply.body());
} catch (RuntimeException | UnsupportedEncodingException e) {
handler.handle(Future.failedFuture(e));
return;
}
} else {
handler.handle(Future.failedFuture("Cannot handle Content-Type: " + reply.headers().get("Content-Type")));
return;
}
processNonStandardHeaders(userInfo, reply, config.getScopeSeparator());
handler.handle(Future.succeededFuture(userInfo));
});
}
public String endSessionURL(String idToken, JsonObject params) {
final String path = config.getLogoutPath();
if (path == null) {
return null;
}
final JsonObject query = params.copy();
if (idToken != null) {
query.put("id_token_hint", idToken);
}
final String url = path.charAt(0) == '/' ? config.getSite() + path : path;
return url + '?' + SimpleHttpClient.jsonToQuery(query).toString();
}
public void logout(String accessToken, String refreshToken, Handler<AsyncResult<Void>> callback) {
final JsonObject headers = new JsonObject();
headers.put("Authorization", "Bearer " + accessToken);
JsonObject tmp = config.getHeaders();
if (tmp != null) {
headers.mergeIn(tmp);
}
final JsonObject form = new JsonObject();
form.put("client_id", config.getClientID());
if (config.getClientSecretParameterName() != null && config.getClientSecret() != null) {
form.put(config.getClientSecretParameterName(), config.getClientSecret());
}
if (refreshToken != null) {
form.put("refresh_token", refreshToken);
}
headers.put("Content-Type", "application/x-www-form-urlencoded");
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
fetch(
HttpMethod.POST,
config.getLogoutPath(),
headers,
payload,
res -> {
if (res.succeeded()) {
callback.handle(Future.succeededFuture());
} else {
callback.handle(Future.failedFuture(res.cause()));
}
});
}
private String (JsonObject json) {
String description;
Object error = json.getValue("error");
if (error instanceof JsonObject) {
description = ((JsonObject) error).getString("message");
} else {
try {
description = json.getString("error_description", json.getString("error"));
} catch (RuntimeException e) {
description = error.toString();
}
}
return description;
}
public void fetch(HttpMethod method, String path, JsonObject headers, Buffer payload, Handler<AsyncResult<OAuth2Response>> callback) {
if (path == null || path.length() == 0) {
callback.handle(Future.failedFuture("Invalid path"));
return;
}
final String url = path.charAt(0) == '/' ? config.getSite() + path : path;
LOG.debug("Fetching URL: " + url);
RequestOptions options = new RequestOptions().setMethod(method).setAbsoluteURI(url);
JsonObject tmp = config.getHeaders();
if (tmp != null) {
for (Map.Entry<String, Object> kv : tmp) {
options.addHeader(kv.getKey(), (String) kv.getValue());
}
}
if (headers != null) {
for (Map.Entry<String, Object> kv : headers) {
options.addHeader(kv.getKey(), (String) kv.getValue());
}
}
if (config.getUserAgent() != null) {
options.addHeader("User-Agent", config.getUserAgent());
}
if (method != HttpMethod.POST && method != HttpMethod.PATCH && method != HttpMethod.PUT) {
payload = null;
}
makeRequest(options, payload, callback);
}
private void makeRequest(RequestOptions options, Buffer payload, final Handler<AsyncResult<OAuth2Response>> callback) {
client.request(options, request -> {
if (request.failed()) {
callback.handle(Future.failedFuture(request.cause()));
return;
}
final HttpClientRequest req = request.result();
final Handler<AsyncResult<HttpClientResponse>> resultHandler = send -> {
if (send.failed()) {
callback.handle(Future.failedFuture(send.cause()));
return;
}
final HttpClientResponse res = send.result();
res.body(body -> {
if (body.succeeded()) {
Buffer value = body.result();
if (res.statusCode() < 200 || res.statusCode() >= 300) {
if (value == null || value.length() == 0) {
callback.handle(Future.failedFuture(res.statusMessage()));
} else {
callback.handle(Future.failedFuture(res.statusMessage() + ": " + value.toString()));
}
} else {
callback.handle(Future.succeededFuture(new OAuth2Response(res.statusCode(), res.headers(), value)));
}
} else {
callback.handle(Future.failedFuture(body.cause()));
}
});
};
if (payload != null) {
req.send(payload, resultHandler);
} else {
req.send(resultHandler);
}
});
}
public static void processNonStandardHeaders(JsonObject json, OAuth2Response reply, String sep) {
final String xOAuthScopes = reply.getHeader("X-OAuth-Scopes");
final String xAcceptedOAuthScopes = reply.getHeader("X-Accepted-OAuth-Scopes");
if (xOAuthScopes != null) {
LOG.trace("Received non-standard X-OAuth-Scopes: "+ xOAuthScopes);
if (json.containsKey("scope")) {
json.put("scope", json.getString("scope") + sep + xOAuthScopes);
} else {
json.put("scope", xOAuthScopes);
}
}
if (xAcceptedOAuthScopes != null) {
LOG.trace("Received non-standard X-Accepted-OAuth-Scopes: "+ xAcceptedOAuthScopes);
json.put("acceptedScopes", xAcceptedOAuthScopes);
}
}
}