Skip to content

Commit

Permalink
Implement OAuth 2 support (#448)
Browse files Browse the repository at this point in the history
Co-authored-by: Sergio del Amo <sergio.delamo@softamo.com>
  • Loading branch information
alvarosanchez and sdelamo authored Feb 2, 2021
1 parent 353250b commit 530914d
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@
* @author croudet
*/
public final class OpenApiViewConfig {

private static final String TEMPLATES = "templates";
private static final String TEMPLATES_SWAGGER_UI = "swagger-ui";
private static final String TEMPLATES_REDOC = "redoc";
private static final String TEMPLATES_RAPIDOC = "rapidoc";
private static final String TEMPLATE_INDEX_HTML = "index.html";
private static final String REDOC = "redoc";
private static final String RAPIDOC = "rapidoc";
private static final String SWAGGER_UI = "swagger-ui";
private static final String TEMPLATE_OAUTH_2_REDIRECT_HTML = "oauth2-redirect.html";
private static final String SLASH = "/";

private String mappingPath;
private String title;
private String specFile;
Expand Down Expand Up @@ -114,16 +126,20 @@ public boolean isEnabled() {
*/
public void render(Path outputDir, VisitorContext visitorContext) throws IOException {
if (redocConfig != null) {
render(redocConfig, outputDir.resolve("redoc"), "templates/redoc/index.html", visitorContext);
render(redocConfig, outputDir.resolve(REDOC), TEMPLATES + SLASH + TEMPLATES_REDOC + SLASH + TEMPLATE_INDEX_HTML, visitorContext);
}
if (rapidocConfig != null) {
render(rapidocConfig, outputDir.resolve("rapidoc"), "templates/rapidoc/index.html", visitorContext);
render(rapidocConfig, outputDir.resolve(RAPIDOC), TEMPLATES + SLASH + TEMPLATES_RAPIDOC + SLASH + TEMPLATE_INDEX_HTML, visitorContext);
}
if (swaggerUIConfig != null) {
render(swaggerUIConfig, outputDir.resolve("swagger-ui"), "templates/swagger-ui/index.html", visitorContext);
Path dir = outputDir.resolve(SWAGGER_UI);
render(swaggerUIConfig, dir, TEMPLATES + SLASH + TEMPLATES_SWAGGER_UI + SLASH + TEMPLATE_INDEX_HTML, visitorContext);
if (SwaggerUIConfig.hasOauth2Option(swaggerUIConfig.options)) {
render(swaggerUIConfig, dir, TEMPLATES + SLASH + TEMPLATES_SWAGGER_UI + SLASH + TEMPLATE_OAUTH_2_REDIRECT_HTML, visitorContext);
}
}
}

private String readTemplateFromClasspath(String templateName) throws IOException {
StringBuilder buf = new StringBuilder(1024);
ClassLoader classLoader = getClass().getClassLoader();
Expand All @@ -147,7 +163,8 @@ private void render(Renderer renderer, Path outputDir, String templateName, Visi
if (!Files.exists(outputDir)) {
Files.createDirectories(outputDir);
}
Path file = outputDir.resolve("index.html");
String fileName = templateName.substring(templateName.lastIndexOf(SLASH) + 1);
Path file = outputDir.resolve(fileName);
if (visitorContext != null) {
visitorContext.info("Writing OpenAPI View to destination: " + file);
visitorContext.getClassesOutputPath().ifPresent(path ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
import java.util.function.Function;
import java.util.stream.Collectors;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import io.micronaut.core.util.StringUtils;
import io.micronaut.openapi.view.OpenApiViewConfig.RendererType;

/**
Expand All @@ -30,10 +33,19 @@
*/
final class SwaggerUIConfig extends AbstractViewConfig implements Renderer {
private static final Map<String, Object> DEFAULT_OPTIONS = new HashMap<>(4);

private static final String OPTION_OAUTH2 = "oauth2";
private static final String DOT = ".";
private static final String PREFIX_SWAGGER_UI = "swagger-ui";
private static final String KEY_VALUE_SEPARATOR = ": ";
private static final String COMMNA_NEW_LINE = ",\n";

// https://github.com/swagger-api/swagger-ui/blob/HEAD/docs/usage/configuration.md
private static final Map<String, Function<String, Object>> VALID_OPTIONS = new HashMap<>(16);

// https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md
private static final Map<String, Function<String, Object>> VALID_OAUTH2_OPTIONS = new HashMap<>(9);


static {
VALID_OPTIONS.put("layout", AbstractViewConfig::asQuotedString);
VALID_OPTIONS.put("deepLinking", AbstractViewConfig::asBoolean);
Expand All @@ -54,6 +66,16 @@ final class SwaggerUIConfig extends AbstractViewConfig implements Renderer {
VALID_OPTIONS.put("validatorUrl", AbstractViewConfig::asQuotedString);
VALID_OPTIONS.put("withCredentials", AbstractViewConfig::asBoolean);

VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".clientId", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".clientSecret", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".realm", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".appName", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".scopeSeparator", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".scopes", AbstractViewConfig::asQuotedString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".additionalQueryStringParams", AbstractViewConfig::asString);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".useBasicAuthenticationWithAccessCodeGrant", AbstractViewConfig::asBoolean);
VALID_OAUTH2_OPTIONS.put(OPTION_OAUTH2 + ".usePkceWithAuthorizationCodeGrant", AbstractViewConfig::asBoolean);

DEFAULT_OPTIONS.put("layout", "\"StandaloneLayout\"");
DEFAULT_OPTIONS.put("deepLinking", Boolean.TRUE);
DEFAULT_OPTIONS.put("validatorUrl", null);
Expand Down Expand Up @@ -89,12 +111,38 @@ public String getCss() {
}

private SwaggerUIConfig() {
super("swagger-ui.");
super(PREFIX_SWAGGER_UI + DOT);
}

@NonNull
private String toOptions() {
return options.entrySet().stream().map(e -> e.getKey() + ": " + e.getValue())
.collect(Collectors.joining(",\n"));
return toOptions(VALID_OPTIONS, null);
}

private String toOptions(@NonNull Map<String, Function<String, Object>> validOptions,
@Nullable String keyPrefix) {
return options
.entrySet()
.stream()
.filter(e -> validOptions.containsKey(e.getKey()))
.sorted(Map.Entry.comparingByKey())
.map(e -> ((keyPrefix != null && e.getKey().startsWith(keyPrefix)) ? e.getKey().substring(keyPrefix.length()) : e.getKey())
+ KEY_VALUE_SEPARATOR + e.getValue())
.collect(Collectors.joining(COMMNA_NEW_LINE));
}

@NonNull
private String toOauth2Options() {
String properties = toOptions(VALID_OAUTH2_OPTIONS, OPTION_OAUTH2 + DOT);
if (StringUtils.hasText(properties)) {
return "ui.initOAuth({\n" + properties + "\n});";
} else {
return "";
}
}

static boolean hasOauth2Option(Map<String, Object> options) {
return options.containsKey("oauth2RedirectUrl") || VALID_OAUTH2_OPTIONS.keySet().stream().anyMatch(options::containsKey);
}

/**
Expand All @@ -105,26 +153,23 @@ private String toOptions() {
static SwaggerUIConfig fromProperties(Map<String, String> properties) {
SwaggerUIConfig cfg = new SwaggerUIConfig();
cfg.theme = Theme
.valueOf(properties.getOrDefault("swagger-ui.theme", cfg.theme.name()).toUpperCase(Locale.US));
.valueOf(properties.getOrDefault(PREFIX_SWAGGER_UI + ".theme", cfg.theme.name()).toUpperCase(Locale.US));
return AbstractViewConfig.fromProperties(cfg, DEFAULT_OPTIONS, properties);
}

@Override
public String render(String template) {
template = rapiPDFConfig.render(template, RendererType.SWAGGER_UI);
template = OpenApiViewConfig.replacePlaceHolder(template, "swagger-ui.version", version, "@");
template = OpenApiViewConfig.replacePlaceHolder(template, "swagger-ui.attributes", toOptions(), "");
if (theme == null || Theme.DEFAULT.equals(theme)) {
template = template.replace("{{swagger-ui.theme}}", "");
} else {
template = template.replace("{{swagger-ui.theme}}", "<link rel='stylesheet' type='text/css' href='https://unpkg.com/swagger-ui-themes@3.0.0/themes/3.x/" + theme.getCss() + ".css' />");
}
template = OpenApiViewConfig.replacePlaceHolder(template, PREFIX_SWAGGER_UI + ".version", version, "@");
template = OpenApiViewConfig.replacePlaceHolder(template, PREFIX_SWAGGER_UI + ".attributes", toOptions(), "");
template = template.replace("{{" + PREFIX_SWAGGER_UI + ".theme}}", theme == null || Theme.DEFAULT.equals(theme) ? "" :
"<link rel='stylesheet' type='text/css' href='https://unpkg.com/" + PREFIX_SWAGGER_UI + "-themes@3.0.0/themes/3.x/" + theme.getCss() + ".css' />");
template = template.replace("{{" + PREFIX_SWAGGER_UI + DOT + OPTION_OAUTH2 + "}}", hasOauth2Option(options) ? toOauth2Options() : "");
return template;
}

@Override
protected Function<String, Object> getConverter(String key) {
return VALID_OPTIONS.get(key);
return (VALID_OPTIONS.containsKey(key) ? VALID_OPTIONS : VALID_OAUTH2_OPTIONS).get(key);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,6 @@ private boolean isTypeNullable(ClassElement type) {
return type.isAssignable("java.util.Optional");
}


/**
* Resolves the schema for the given type element.
*
Expand Down
1 change: 1 addition & 0 deletions openapi/src/main/resources/templates/swagger-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
],
{{swagger-ui.attributes}}
});
{{swagger-ui.oauth2}}
window.ui = ui;
{{rapipdf.specurl}}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!--
Forked from https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html
-->
<!doctype html>
<html lang="en-US">
<head>
<title>{{title}}</title>
</head>
<body>
</body>
</html>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;

if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}

arr = qp.split("&")
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}

isValid = qp.state === sentState

if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}

if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}

oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}

window.addEventListener('DOMContentLoaded', function () {
run();
});
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,40 @@ class OpenApiOperationViewRenderSpec extends Specification {
Files.exists(outputDir.resolve("redoc").resolve("index.html"))
Files.exists(outputDir.resolve("rapidoc").resolve("index.html"))
Files.exists(outputDir.resolve("swagger-ui").resolve("index.html"))
Files.notExists(outputDir.resolve("swagger-ui").resolve("oauth2-redirect.html"))
outputDir.resolve("redoc").resolve("index.html").toFile().getText(StandardCharsets.UTF_8.name()).contains(cfg.getSpecURL())
outputDir.resolve("rapidoc").resolve("index.html").toFile().getText(StandardCharsets.UTF_8.name()).contains(cfg.getSpecURL())
outputDir.resolve("swagger-ui").resolve("index.html").toFile().getText(StandardCharsets.UTF_8.name()).contains(cfg.getSpecURL())
!outputDir.resolve("swagger-ui").resolve("index.html").toFile().getText(StandardCharsets.UTF_8.name()).contains("ui.initOAuth({")
}

void "test generates oauth2-redirect.html"() {
given:
String spec = "swagger-ui.enabled=true,swagger-ui.oauth2RedirectUrl=http://localhost:8080/foo/bar,swagger-ui.oauth2.clientId=foo,swagger-ui.oauth2.clientSecret=bar"
OpenApiViewConfig cfg = OpenApiViewConfig.fromSpecification(spec, new Properties())
Path outputDir = Paths.get("output")
cfg.title = "OpenAPI documentation"
cfg.specFile = "swagger.yml"
cfg.render(outputDir, null)

expect:
cfg.enabled == true
cfg.swaggerUIConfig != null
cfg.title == "OpenAPI documentation"
cfg.specFile == "swagger.yml"

and:
Path index = outputDir.resolve("swagger-ui").resolve("index.html")
Path oauth2Redirect = outputDir.resolve("swagger-ui").resolve("oauth2-redirect.html")
Files.exists(index)
Files.exists(oauth2Redirect)

and:
String indexHtml = index.toFile().getText(StandardCharsets.UTF_8.name())
indexHtml.contains(cfg.getSpecURL())
indexHtml.contains("ui.initOAuth({")
indexHtml.contains('clientId: "foo"')
indexHtml.contains('clientSecret: "bar"')
}

}
Loading

0 comments on commit 530914d

Please sign in to comment.