io.vertx.ext.auth.oauth2.impl.OAuth2API Maven / Gradle / Ivy
/*
* Copyright 2015 Red Hat, Inc.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* and Apache License v2.0 which accompanies this distribution.
*
* The Eclipse Public License is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* The Apache License v2.0 is available at
* http://www.opensource.org/licenses/apache2.0.php
*
* You may elect to redistribute this code under either of these licenses.
*/
package io.vertx.ext.auth.oauth2.impl;
import io.vertx.codegen.annotations.Nullable;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.*;
import io.vertx.core.internal.logging.Logger;
import io.vertx.core.internal.logging.LoggerFactory;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.impl.http.SimpleHttpClient;
import io.vertx.ext.auth.impl.http.SimpleHttpResponse;
import io.vertx.ext.auth.impl.jose.JWT;
import io.vertx.ext.auth.oauth2.OAuth2AuthorizationURL;
import io.vertx.ext.auth.oauth2.OAuth2Options;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.security.SignatureException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static io.vertx.ext.auth.impl.Codec.base64Encode;
/**
* @author Paulo Lopes
*/
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());
}
/**
* Retrieve the public server JSON Web Key (JWK) required to verify the authenticity of issued ID and access tokens.
*/
public Future jwkSet() {
final JsonObject headers = new JsonObject();
// specify preferred accepted content type, according to https://tools.ietf.org/html/rfc7517#section-8.5
// there's a specific media type for this resource: application/jwk-set+json but we also allow plain application/json
headers.put("Accept", "application/jwk-set+json, application/json");
return fetch(HttpMethod.GET, config.getJwkPath(), headers, null)
.compose(reply -> {
if (reply.body() == null || reply.body().length() == 0) {
return Future.failedFuture("No Body");
}
JsonObject json;
if (reply.is("application/jwk-set+json") || reply.is("application/json")) {
try {
json = new JsonObject(reply.body());
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
} else {
return Future.failedFuture("Cannot handle content type: " + reply.headers().get("Content-Type"));
}
try {
if (json.containsKey("error")) {
return Future.failedFuture(extractErrorDescription(json));
} else {
// process the cache headers as recommended by: https://openid.net/specs/openid-connect-core-1_0.html#RotateEncKeys
List cacheControl = reply.headers().getAll(HttpHeaders.CACHE_CONTROL);
if (cacheControl != null) {
for (String header : cacheControl) {
// we need at least "max-age="
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) {
// ignore bad formed headers
}
}
}
}
}
return Future.succeededFuture(json);
}
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
});
}
/**
* The client sends the end-user's browser to this endpoint to request their authentication and consent. This endpoint is used in the code and implicit OAuth 2.0 flows which require end-user interaction.
*
* see: https://tools.ietf.org/html/rfc6749
*/
public String authorizeURL(OAuth2AuthorizationURL params) {
final JsonObject query = new JsonObject();
if (params.getAdditionalParameters() != null) {
params
.getAdditionalParameters()
.forEach(query::put);
}
query.put("state", params.getState());
if (params.getScopes() != null) {
// scopes have been passed as a list so the provider must generate the correct string for it
query
.put("scope", String.join(config.getScopeSeparator(), params.getScopes()));
}
query
.put("response_type", "code");
String clientId = config.getClientId();
if (clientId != null) {
query
.put("client_id", clientId);
} else {
if (config.getClientAssertionType() != null) {
query
.put("client_assertion_type", config.getClientAssertionType());
}
if (config.getClientAssertion() != null) {
query
.put("client_assertion", config.getClientAssertion());
}
}
final String path = config.getAuthorizationPath();
final String url = path.charAt(0) == '/' ? config.getSite() + path : path;
return url + '?' + SimpleHttpClient.jsonToQuery(query);
}
/**
* Post an OAuth 2.0 grant (code, refresh token, resource owner password credentials, client credentials) to obtain an ID and / or access token.
*
* see: https://tools.ietf.org/html/rfc6749
*/
public Future token(String grantType, JsonObject params) {
// quick check and abort
if (grantType == null) {
return Future.failedFuture("Token request requires a grantType other than null");
}
final JsonObject headers = new JsonObject();
final JsonObject form = params.copy();
// Enable the system to send authorization params in the body (for example github does not require to be in the header)
if (config.getExtraParameters() != null) {
form.mergeIn(config.getExtraParameters());
}
form.put("grant_type", grantType);
if (!clientAuthentication(headers, form)) {
String clientId = config.getClientId();
if (clientId == null) {
if (config.getClientAssertionType() != null) {
form
.put("client_assertion_type", config.getClientAssertionType());
}
if (config.getClientAssertion() != null) {
form
.put("client_assertion", config.getClientAssertion());
}
}
}
headers.put("Content-Type", "application/x-www-form-urlencoded");
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
// specify preferred accepted content type
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
return fetch(HttpMethod.POST, config.getTokenPath(), headers, payload)
.compose(reply -> {
if (reply.body() == null || reply.body().length() == 0) {
return Future.failedFuture("No Body");
}
JsonObject json;
if (reply.is("application/json")) {
try {
json = reply.jsonObject();
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
json = SimpleHttpClient.queryToJson(reply.body());
} catch (UnsupportedEncodingException | RuntimeException e) {
return Future.failedFuture(e);
}
} else {
return Future.failedFuture("Cannot handle content type: " + reply.headers().get("Content-Type"));
}
try {
if (json == null || json.containsKey("error")) {
return Future.failedFuture(extractErrorDescription(json));
} else {
OAuth2API.processNonStandardHeaders(json, reply, config.getScopeSeparator());
return Future.succeededFuture(json);
}
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
});
}
/**
* Validate an access token and retrieve its underlying authorisation (for resource servers).
*
* see: https://tools.ietf.org/html/rfc7662
*/
public Future tokenIntrospection(String tokenType, String token) {
final JsonObject headers = new JsonObject()
.put("Content-Type", "application/x-www-form-urlencoded");
final JsonObject form = new JsonObject()
.put("token", token)
// optional param from RFC7662
.put("token_type_hint", tokenType);
clientAuthentication(headers, form);
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
// specify preferred accepted accessToken type
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
return fetch(HttpMethod.POST, config.getIntrospectionPath(), headers, payload)
.compose(reply -> {
if (reply.body() == null || reply.body().length() == 0) {
return Future.failedFuture("No Body");
}
JsonObject json;
if (reply.is("application/json")) {
try {
json = reply.jsonObject();
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
json = SimpleHttpClient.queryToJson(reply.body());
} catch (UnsupportedEncodingException | RuntimeException e) {
return Future.failedFuture(e);
}
} else {
return Future.failedFuture("Cannot handle accessToken type: " + reply.headers().get("Content-Type"));
}
try {
if (json == null || json.containsKey("error")) {
return Future.failedFuture(extractErrorDescription(json));
} else {
processNonStandardHeaders(json, reply, config.getScopeSeparator());
return Future.succeededFuture(json);
}
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
});
}
/**
* Revoke an obtained access or refresh token.
*
* see: https://tools.ietf.org/html/rfc7009
*/
public Future tokenRevocation(String tokenType, String token) {
if (token == null) {
return Future.failedFuture("Cannot revoke null token");
}
final JsonObject headers = new JsonObject()
.put("Content-Type", "application/x-www-form-urlencoded");
final JsonObject form = new JsonObject()
.put("token", token)
.put("token_type_hint", tokenType);
clientAuthentication(headers, form);
final Buffer payload = SimpleHttpClient.jsonToQuery(form);
// specify preferred accepted accessToken type
headers.put("Accept", "application/json,application/x-www-form-urlencoded;q=0.9");
return fetch(HttpMethod.POST, config.getRevocationPath(), headers, payload)
.compose(reply -> {
if (reply.body() == null) {
return Future.failedFuture("No Body");
}
return Future.succeededFuture();
});
}
/**
* Retrieve profile information and other attributes for a logged-in end-user.
*
* see: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
*/
public Future userInfo(String accessToken, JWT jwt) {
final JsonObject headers = new JsonObject();
final JsonObject extraParams = config.getUserInfoParameters();
String path = config.getUserInfoPath();
if (path == null) {
return Future.failedFuture("userInfo path is not configured");
}
if (extraParams != null) {
path += "?" + SimpleHttpClient.jsonToQuery(extraParams);
}
headers.put("Authorization", "Bearer " + accessToken);
// specify preferred accepted accessToken type
headers.put("Accept", "application/json,application/jwt,application/x-www-form-urlencoded;q=0.9");
return fetch(HttpMethod.GET, path, headers, null)
.compose(reply -> {
Buffer body = reply.body();
if (body == null) {
return Future.failedFuture("No Body");
}
// userInfo is expected to be an object
JsonObject userInfo;
if (reply.is("application/json")) {
try {
// userInfo is expected to be an object
userInfo = reply.jsonObject();
} catch (RuntimeException e) {
return Future.failedFuture(e);
}
} else if (reply.is("application/jwt")) {
try {
// userInfo is expected to be a JWT
userInfo = jwt.decode(body.toString(StandardCharsets.UTF_8));
} catch (SignatureException | RuntimeException e) {
return Future.failedFuture(e);
}
} else if (reply.is("application/x-www-form-urlencoded") || reply.is("text/plain")) {
try {
// attempt to convert url encoded string to json
userInfo = SimpleHttpClient.queryToJson(reply.body());
} catch (RuntimeException | UnsupportedEncodingException e) {
return Future.failedFuture(e);
}
} else {
return Future.failedFuture("Cannot handle Content-Type: " + reply.headers().get("Content-Type"));
}
processNonStandardHeaders(userInfo, reply, config.getScopeSeparator());
return Future.succeededFuture(userInfo);
});
}
/**
* The logout (end-session) endpoint is specified in OpenID Connect Session Management 1.0.
*
* see: https://openid.net/specs/openid-connect-session-1_0.html
*/
public @Nullable String endSessionURL(String idToken, JsonObject params) {
final String path = config.getLogoutPath();
if (path == null) {
// we can't generate anything, there's no configured logout path
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);
}
private boolean clientAuthentication(JsonObject headers, JsonObject form) {
final boolean confidentialClient = config.getClientId() != null && config.getClientSecret() != null;
if (confidentialClient) {
if (config.isUseBasicAuthorization()) {
String basic = config.getClientId() + ":" + config.getClientSecret();
headers.put("Authorization", "Basic " + base64Encode(basic.getBytes(StandardCharsets.UTF_8)));
} else {
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
}
} else {
if (config.getClientId() != null) {
form.put("client_id", config.getClientId());
}
}
return confidentialClient;
}
private String extractErrorDescription(JsonObject json) {
if (json == null) {
return "null";
}
String description;
Object error = json.getValue("error");
if (error instanceof JsonObject) {
description = ((JsonObject) error).getString("message");
} else {
// attempt to handle the error as a string
try {
description = json.getString("error_description", json.getString("error"));
} catch (RuntimeException e) {
description = error.toString();
}
}
if (description == null) {
return "null";
}
return description;
}
public Future fetch(HttpMethod method, String path, JsonObject headers, Buffer payload) {
if (path == null || path.length() == 0) {
// and this can happen as it is a config option that is dependent on the provider
return Future.failedFuture("Invalid path");
}
final String url = path.charAt(0) == '/' ? config.getSite() + path : path;
LOG.debug("Fetching URL: " + url);
RequestOptions options = new RequestOptions().setMethod(method).setAbsoluteURI(url);
// apply the provider required headers
JsonObject tmp = config.getHeaders();
if (tmp != null) {
for (Map.Entry kv : tmp) {
options.addHeader(kv.getKey(), (String) kv.getValue());
}
}
if (headers != null) {
for (Map.Entry kv : headers) {
options.addHeader(kv.getKey(), (String) kv.getValue());
}
}
// specific UA
if (config.getUserAgent() != null) {
options.addHeader("User-Agent", config.getUserAgent());
}
if (method != HttpMethod.POST && method != HttpMethod.PATCH && method != HttpMethod.PUT) {
payload = null;
}
// create a request
return makeRequest(options, payload);
}
private Future makeRequest(RequestOptions options, Buffer payload) {
return client.request(options)
.compose(req -> {
final Function> resultHandler = res -> {
// read the body regardless
return res.body()
.compose(body -> {
final SimpleHttpResponse oauth2res = new SimpleHttpResponse(res.statusCode(), res.headers(), body);
if (res.statusCode() < 200 || res.statusCode() >= 300) {
if (oauth2res.body() == null || oauth2res.body().length() == 0) {
return Future.failedFuture(res.statusMessage());
} else {
if (oauth2res.is("application/json")) {
// if value is json, extract error, error_descriptions
try {
JsonObject error = oauth2res.jsonObject();
if (error != null && error.containsKey("error")) {
if (error.containsKey("error_description")) {
return Future.failedFuture(error.getString("error") + ": " + error.getString("error_description"));
} else {
return Future.failedFuture(error.getString("error"));
}
}
} catch (RuntimeException e) {
// ignore, we can't parse the json, don't mind, rely on the status code anyway
}
}
return Future.failedFuture(res.statusMessage() + ": " + oauth2res.body());
}
} else {
return Future.succeededFuture(oauth2res);
}
});
};
// send
if (payload != null) {
return req.send(payload)
.compose(resultHandler);
} else {
return req.send()
.compose(resultHandler);
}
});
}
public static void processNonStandardHeaders(JsonObject json, SimpleHttpResponse reply, String sep) {
// inspect the response headers for the non-standard:
// X-OAuth-Scopes and X-Accepted-OAuth-Scopes
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);
}
}
}