Skip to content

Commit

Permalink
Merge pull request #10651 from boosey/master
Browse files Browse the repository at this point in the history
Oidc Code Flow options for SPA's
  • Loading branch information
sberyozkin authored Jul 21, 2020
2 parents 850e785 + c415f86 commit d12858f
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,31 @@ If only the access token contains the roles and this access token is not meant t

If UserInfo is the source of the roles then set `quarkus.oidc.user-info-required=true` and `quarkus.oidc.roles.source=userinfo`, and if needed, `quarkus.oidc.roles.role-claim-path`.

== Single Page Applications

Please check if implementing SPAs the way it is suggested in the link:security-openid-connect#single-page-applications[Single Page Applications for Service Applications] section can meet your requirements.

If you do prefer to use SPA and `XMLHttpRequest`(XHR) with Quarkus `web-app` applications then please be aware that OpenId Connect Providers may not support CORS for Authorization endpoints where the users
are authenticated after a redirect from Quarkus which will lead to the authentication failures if the Quarkus `web-app` application and OpenId Connect Provider are hosted on the different HTTP domains/ports.

In such cases one needs to set the `quarkus.oidc.authentication.xhr-auto-redirect` property to `false` which will instruct Quarkus to return a `499` status code and `WWW-Authenticate` header with the `OIDC` value and the browser script needs to be updated to set "X-Requested-With" header with the `XMLHttpRequest` value and reload the last requested page in case of `499`, for example:

[source,javascript]
----
Future<void> callQuarkusService() async {
Map<String, String> headers = Map.fromEntries([MapEntry("X-Requested-With", "XMLHttpRequest")]);
await http
.get("https://localhost:443/serviceCall")
.then((response) {
if (response.statusCode == 499) {
window.location.assign(https://localhost.com:443/serviceCall);
}
});
}
----


== Configuration Reference

include::{generated-dir}/config/quarkus-oidc.adoc[opts=optional]
Expand Down
49 changes: 49 additions & 0 deletions docs/src/main/asciidoc/security-openid-connect.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,55 @@ If the token is opaque (binary) then a `scope` property from the remote token in

Additionally a custom `SecurityIdentityAugmentor` can also be used to add the roles as documented link:security#security-identity-customization[here].

[[oidc-single-page-applications]]
== Single Page Applications

Single Page Application (SPA) typically uses `XMLHttpRequest`(XHR) and the Java Script utility code provided by the OpenId Connect provider to acquire a bearer token and use it
to access Quarkus `service` applications.

For example, here is how you can use `keycloak.js` to authenticate the users and refresh the expired tokens from the SPA:

[source,html]
----
<html>
<head>
<title>keycloak-spa</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="http://localhost:8180/auth/js/keycloak.js"></script>
<script>
var keycloak = new Keycloak();
keycloak.init({onLoad: 'login-required'}).success(function () {
console.log('User is now authenticated.');
}).error(function () {
window.location.reload();
});
function makeAjaxRequest() {
axios.get("/api/hello", {
headers: {
'Authorization': 'Bearer ' + keycloak.token
}
})
.then( function (response) {
console.log("Response: ", response.status);
}).catch(function (error) {
console.log('refreshing');
keycloak.updateToken(5).then(function () {
console.log('Token refreshed');
}).catch(function () {
console.log('Failed to refresh token');
window.location.reload();
});
});
}
</script>
</head>
<body>
<button onclick="makeAjaxRequest()">Request</button>
</body>
</html>
----


== References

* https://www.keycloak.org/documentation.html[Keycloak Documentation]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,26 @@ public static class Authentication {
@ConfigItem(defaultValue = "false")
public boolean userInfoRequired;

/**
* If this property is set to 'true' then a normal 302 redirect response will be returned
* if the request was initiated via XMLHttpRequest and the current user needs to be
* (re)authenticated which may not be desirable for Single Page Applications since
* XMLHttpRequest automatically following the redirect may not work given that OIDC
* authorization endpoints typically do not support CORS.
* If this property is set to `false` then a status code of '499' will be returned to allow
* the client to handle the redirect manually
*/
@ConfigItem(defaultValue = "true")
public boolean xhrAutoRedirect = true;

public boolean isXhrAutoRedirect() {
return xhrAutoRedirect;
}

public void setXhrAutoredirect(boolean autoRedirect) {
this.xhrAutoRedirect = autoRedirect;
}

public Optional<String> getRedirectPath() {
return redirectPath;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,29 @@ public SecurityIdentity apply(Throwable throwable) {
return performCodeFlow(identityProviderManager, context, resolver);
}

private boolean isXHR(RoutingContext context) {
return "XMLHttpRequest".equals(context.request().getHeader("X-Requested-With"));
}

// This test determines if the default behavior of returning a 302 should go forward
// The only case that shouldn't return a 302 is if the call is a XHR and the
// user has set the auto direct application property to false indicating that
// the client application will manually handle the redirect to account for SPA behavior
private boolean shouldAutoRedirect(TenantConfigContext configContext, RoutingContext context) {
return isXHR(context) ? configContext.oidcConfig.authentication.xhrAutoRedirect : true;
}

public Uni<ChallengeData> getChallenge(RoutingContext context, DefaultTenantConfigResolver resolver) {

TenantConfigContext configContext = resolver.resolve(context, true);
removeCookie(context, configContext, getSessionCookieName(configContext));

ChallengeData challenge;
if (!shouldAutoRedirect(configContext, context)) {
// If the client (usually an SPA) wants to handle the redirect manually, then
// return status code 499 and WWW-Authenticate header with the 'OIDC' value.
return Uni.createFrom().item(new ChallengeData(499, "WWW-Authenticate", "OIDC"));
}

JsonObject params = new JsonObject();

// scope
Expand All @@ -168,10 +185,8 @@ public Uni<ChallengeData> getChallenge(RoutingContext context, DefaultTenantConf
}
}

challenge = new ChallengeData(HttpResponseStatus.FOUND.code(), HttpHeaders.LOCATION,
configContext.auth.authorizeURL(params));

return Uni.createFrom().item(challenge);
return Uni.createFrom().item(new ChallengeData(HttpResponseStatus.FOUND.code(), HttpHeaders.LOCATION,
configContext.auth.authorizeURL(params)));
}

private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityProviderManager,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ public AuthenticationRedirectException(int code, String redirectUri) {
this.redirectUri = redirectUri;
}

public AuthenticationRedirectException(Boolean autoRedirect, String redirectUri) {
this(autoRedirect ? 302 : 444, redirectUri);
}

public int getCode() {
return 302;
return this.code;
}

public String getRedirectUri() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public String resolve(RoutingContext context) {
return "tenant-https";
}

if (path.contains("tenant-xhr")) {
return "tenant-xhr";
}

return path.contains("callback-after-redirect") || path.contains("callback-before-redirect") ? "tenant-1"
: path.contains("callback-jwt-after-redirect") || path.contains("callback-jwt-before-redirect") ? "tenant-jwt"
: path.contains("callback-jwt-not-used-after-redirect")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,23 @@ quarkus.oidc.tenant-https.authentication.extra-params.max-age=60
quarkus.oidc.tenant-https.application-type=web-app
quarkus.oidc.tenant-https.authentication.force-redirect-https-scheme=true

quarkus.oidc.tenant-xhr.auth-server-url=${keycloak.url}/realms/quarkus
quarkus.oidc.tenant-xhr.client-id=quarkus-app
quarkus.oidc.tenant-xhr.credentials.secret=secret
quarkus.oidc.tenant-xhr.authentication.xhr-auto-redirect=false
quarkus.oidc.tenant-xhr.application-type=web-app

quarkus.http.auth.permission.roles1.paths=/index.html
quarkus.http.auth.permission.roles1.policy=authenticated

quarkus.http.auth.permission.logout.paths=/tenant-logout
quarkus.http.auth.permission.logout.policy=authenticated

quarkus.http.auth.permission.logout.paths=/tenant-https
quarkus.http.auth.permission.logout.policy=authenticated
quarkus.http.auth.permission.https.paths=/tenant-https
quarkus.http.auth.permission.https.policy=authenticated

quarkus.http.auth.permission.xhr.paths=/tenant-xhr
quarkus.http.auth.permission.xhr.policy=authenticated

quarkus.http.auth.permission.post-logout.paths=/tenant-logout/post-logout
quarkus.http.auth.permission.post-logout.policy=permit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,22 @@ public void testAccessAndRefreshTokenInjectionWithoutIndexHtmlWithQuery() throws
}
}

@Test
public void testXhrRequest() throws IOException, InterruptedException {
try (final WebClient webClient = createWebClient()) {
try {
webClient.addRequestHeader("X-Requested-With", "XMLHttpRequest");
webClient.getPage("http://localhost:8081/tenant-xhr");
fail("499 status error is expected");
} catch (FailingHttpStatusCodeException ex) {
assertEquals(499, ex.getStatusCode());
assertEquals("OIDC", ex.getResponse().getResponseHeaderValue("WWW-Authenticate"));
}

webClient.getCookieManager().clearCookies();
}
}

@Test
public void testNoCodeFlowUnprotected() {
RestAssured.when().get("/public-web-app/access")
Expand Down

0 comments on commit d12858f

Please sign in to comment.