Skip to content

Commit

Permalink
Merge pull request #35923 from michalvavrik/feature/oidc-map-scopes-t…
Browse files Browse the repository at this point in the history
…o-identity-permissions

Map OIDC scope attribute to the SecurityIdentity permissions
  • Loading branch information
sberyozkin authored Sep 15, 2023
2 parents 6e04f9a + 386e333 commit 8677d59
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ It is possible to use multiple expressions in the role definition.
In development mode, it allows any authenticated user.
<5> Property expression `all-roles` will be treated as a collection type `List`, therefore the endpoint will be accessible for roles `Administrator`, `Software`, `Tester` and `User`.

[[permission-annotation]]
=== Permission annotation

Quarkus also provides the `io.quarkus.security.PermissionsAllowed` annotation, which authorizes any authenticated user with the given permission to access the resource.
Expand Down Expand Up @@ -728,3 +729,4 @@ CAUTION: Annotation permissions do not work with the custom xref:security-custom
* xref:security-authentication-mechanisms.adoc#other-supported-authentication-mechanisms[Authentication mechanisms in Quarkus]
* xref:security-basic-authentication.adoc[Basic authentication]
* xref:security-basic-authentication-tutorial.adoc[Secure a Quarkus application with Basic authentication and Jakarta Persistence]
* xref:security-oidc-bearer-token-authentication.adoc#token-scopes-and-security-identity-permissions[OpenID Connect Bearer Token Scopes And SecurityIdentity Permissions]
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ The current tenant's discovered link:https://openid.net/specs/openid-connect-dis

The default tenant's `OidcConfigurationMetadata` is injected if the endpoint is public.

[[token-claims-and-security-identity-roles]]
=== Token Claims And SecurityIdentity Roles

SecurityIdentity roles can be mapped from the verified JWT access tokens as follows:
Expand All @@ -112,6 +113,50 @@ If UserInfo is the source of the roles then set `quarkus.oidc.authentication.use

Additionally, a custom `SecurityIdentityAugmentor` can also be used to add the roles as documented in xref:security-customization.adoc#security-identity-customization[Security Identity Customization].

[[token-scopes-and-security-identity-permissions]]
=== Token scopes And SecurityIdentity permissions

SecurityIdentity permissions are mapped in the form of the `io.quarkus.security.StringPermission` from the scope parameter of the <<token-claims-and-security-identity-roles,source of the roles>>, using the same claim separator.

[source, java]
----
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.security.PermissionsAllowed;
@Path("/service")
public class ProtectedResource {
@Inject
JsonWebToken accessToken;
@PermissionsAllowed("email") <1>
@GET
@Path("/email")
public Boolean isUserEmailAddressVerifiedByUser() {
return accessToken.getClaim(Claims.email_verified.name());
}
@PermissionsAllowed("orders_read") <2>
@GET
@Path("/order")
public List<Order> listOrders() {
return List.of(new Order(1));
}
}
----
<1> Only requests with OpenID Connect scope `email` are going to be granted access.
<2> The read access is limited to the client requests with scope `orders_read`.

Please refer to the Permission annotation section of the xref:security-authorize-web-endpoints-reference.adoc#permission-annotation[Authorization of web endpoints]
guide for more information about the `io.quarkus.security.PermissionsAllowed` annotation.

[[token-verification-introspection]]
=== Token Verification And Introspection

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ && tokenAutoRefreshPrepared(result, vertxContext, resolvedContext.oidcConfig)) {
Set<String> scopes = result.introspectionResult.getScopes();
if (scopes != null) {
builder.addRoles(scopes);
OidcUtils.addTokenScopesAsPermissions(builder, scopes);
}
}
builder.setPrincipal(new Principal() {
Expand All @@ -339,8 +340,9 @@ public String getName() {
}
});
if (userInfo != null) {
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig,
new JsonObject(userInfo.getJsonObject().toString()));
var rolesJson = new JsonObject(userInfo.getJsonObject().toString());
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig, rolesJson);
OidcUtils.setSecurityIdentityPermissions(builder, resolvedContext.oidcConfig, rolesJson);
}
OidcUtils.setBlockingApiAttribute(builder, vertxContext);
OidcUtils.setTenantIdAttribute(builder, resolvedContext.oidcConfig);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
package io.quarkus.oidc.runtime;

import static io.quarkus.oidc.common.runtime.OidcConstants.TOKEN_SCOPE;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Permission;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Pattern;

import javax.crypto.SecretKey;
Expand All @@ -37,6 +42,7 @@
import io.quarkus.oidc.UserInfo;
import io.quarkus.oidc.runtime.providers.KnownOidcProviders;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.StringPermission;
import io.quarkus.security.credential.TokenCredential;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
Expand Down Expand Up @@ -261,6 +267,7 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(
builder.setPrincipal(jwtPrincipal);
setRoutingContextAttribute(builder, vertxContext);
setSecurityIdentityRoles(builder, config, rolesJson);
setSecurityIdentityPermissions(builder, config, rolesJson);
setSecurityIdentityUserInfo(builder, userInfo);
setSecurityIdentityIntrospection(builder, introspectionResult);
setSecurityIdentityConfigMetadata(builder, resolvedContext);
Expand All @@ -269,6 +276,41 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(
return builder.build();
}

static void setSecurityIdentityPermissions(QuarkusSecurityIdentity.Builder builder, OidcTenantConfig config,
JsonObject permissionsJson) {
addTokenScopesAsPermissions(builder, findClaimWithRoles(config.getRoles(), TOKEN_SCOPE, permissionsJson));
}

static void addTokenScopesAsPermissions(Builder builder, Collection<String> scopes) {
if (!scopes.isEmpty()) {
builder.addPermissionChecker(new Function<Permission, Uni<Boolean>>() {

private final Permission[] permissions = transformScopesToPermissions(scopes);

@Override
public Uni<Boolean> apply(Permission requiredPermission) {
for (Permission possessedPermission : permissions) {
if (possessedPermission.implies(requiredPermission)) {
// access granted
return Uni.createFrom().item(Boolean.TRUE);
}
}
// access denied
return Uni.createFrom().item(Boolean.FALSE);
}
});
}
}

private static Permission[] transformScopesToPermissions(Collection<String> scopes) {
final Permission[] permissions = new Permission[scopes.size()];
int i = 0;
for (String scope : scopes) {
permissions[i++] = new StringPermission(scope);
}
return permissions;
}

public static void setSecurityIdentityRoles(QuarkusSecurityIdentity.Builder builder, OidcTenantConfig config,
JsonObject rolesJson) {
String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,18 @@ public OidcTenantConfig get() {
config.setTenantId("tenant-oidc");
String uri = context.request().absoluteURI();
// authServerUri points to the JAX-RS `OidcResource`, root path is `/oidc`
String authServerUri = path.contains("tenant-opaque")
? uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc")
: uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
final String authServerUri;
if (path.contains("tenant-opaque")) {
if (path.endsWith("/tenant-opaque/tenant-oidc/api/user")) {
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc");
} else if (path.endsWith("/tenant-opaque/tenant-oidc/api/user-permission")) {
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/user-permission", "/oidc");
} else {
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/admin-permission", "/oidc");
}
} else {
authServerUri = uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
}
config.setAuthServerUrl(authServerUri);
config.setClientId("client");
config.setAllowTokenIntrospectionCache(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.oidc.IdToken;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.PermissionsAllowed;

@Path("/tenants")
public class TenantHybridResource {
Expand All @@ -23,4 +25,19 @@ public class TenantHybridResource {
public String userNameService() {
return idToken.getName() != null ? (idToken.getName() + ":web-app") : (accessToken.getName() + ":service");
}

@GET
@Path("/{tenant-hybrid}/api/mp-scope")
@PermissionsAllowed("microprofile-jwt")
public String microProfileScopeService() {
return accessToken.getClaim(OidcConstants.TOKEN_SCOPE);
}

@GET
@Path("/{tenant-hybrid}/api/non-existent-scope")
@PermissionsAllowed("microprofile-jwt")
@PermissionsAllowed("nonexistent-scope")
public String nonExistentScopeService() {
return accessToken.getClaim(OidcConstants.TOKEN_SCOPE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.security.Authenticated;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/tenant-opaque")
Expand Down Expand Up @@ -42,6 +43,20 @@ public String userName() {
+ ":" + tokenIntrospection.getString("email");
}

@GET
@PermissionsAllowed("user")
@Path("tenant-oidc/api/user-permission")
public String userPermission() {
return "user";
}

@GET
@PermissionsAllowed("admin")
@Path("tenant-oidc/api/admin-permission")
public String adminPermission() {
return "admin";
}

@GET
@Path("tenant-oidc-no-opaque-token/api/user")
public String userNameNoOpaqueToken() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.quarkus.it.keycloak;

import java.util.Arrays;
import java.util.stream.Collectors;

import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
Expand All @@ -19,6 +22,8 @@
import io.quarkus.oidc.UserInfo;
import io.quarkus.oidc.client.OidcClientConfig;
import io.quarkus.oidc.client.OidcClients;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.PermissionsAllowed;
import io.quarkus.security.identity.SecurityIdentity;
import io.vertx.ext.web.RoutingContext;

Expand Down Expand Up @@ -161,6 +166,19 @@ public String userNameWebApp2(@PathParam("tenant") String tenant) {
return tenant + ":" + getNameWebAppType(idToken.getName(), "preferred_username", "upn");
}

@GET
@Path("webapp2-scope-permissions")
@PermissionsAllowed({ "openid", "email", "profile" })
public String scopePermissionsWebApp2(@PathParam("tenant") String tenant) {
if (!tenant.equals("tenant-web-app2")) {
throw new OIDCException("Wrong tenant");
}
return Arrays
.stream(accessToken.<String> getClaim(OidcConstants.TOKEN_SCOPE).split(" "))
.sorted(String::compareTo)
.collect(Collectors.joining(" "));
}

private String getNameWebAppType(String name,
String idTokenNameClaim,
String idTokenNameClaimNotExpected) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,18 @@ public void testJavaScriptRequest() throws IOException, InterruptedException {

@Test
public void testResolveTenantIdentifierWebApp2() throws IOException {
testTenantWebApp2("webapp2", "tenant-web-app2:alice");
}

@Test
public void testScopePermissionsFromAccessToken() throws IOException {
// source of permissions is access token
testTenantWebApp2("webapp2-scope-permissions", "email openid profile");
}

private void testTenantWebApp2(String webApp2SubPath, String expectedResult) throws IOException {
try (final WebClient webClient = createWebClient()) {
HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/webapp2");
HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/" + webApp2SubPath);
// State cookie is available but there must be no saved path parameter
// as the tenant-web-app configuration does not set a redirect-path property
assertNull(getStateCookieSavedPath(webClient, "tenant-web-app2"));
Expand All @@ -116,7 +126,7 @@ public void testResolveTenantIdentifierWebApp2() throws IOException {
loginForm.getInputByName("username").setValueAttribute("alice");
loginForm.getInputByName("password").setValueAttribute("alice");
page = loginForm.getInputByName("login").click();
assertEquals("tenant-web-app2:alice", page.getBody().asNormalizedText());
assertEquals(expectedResult, page.getBody().asNormalizedText());
webClient.getCookieManager().clearCookies();
}
}
Expand Down Expand Up @@ -235,6 +245,19 @@ public void testHybridWebAppService() throws IOException {
.body(equalTo("alice:service"));
}

@Test
public void testDefaultClientScopeAsPermission() {
RestAssured.given().auth().oauth2(getAccessToken("alice", "hybrid"))
.when().get("/tenants/tenant-hybrid-webapp-service/api/mp-scope")
.then()
.statusCode(200)
.body(equalTo("microprofile-jwt"));
RestAssured.given().auth().oauth2(getAccessToken("alice", "hybrid"))
.when().get("/tenants/tenant-hybrid-webapp-service/api/non-existent-scope")
.then()
.statusCode(403);
}

@Test
public void testResolveTenantIdentifierWebAppNoDiscovery() throws IOException {
try (final WebClient webClient = createWebClient()) {
Expand Down Expand Up @@ -636,6 +659,23 @@ public void testRequiredClaimFail() {
.statusCode(401);
}

@Test
public void testOpaqueTokenScopePermission() {
RestAssured.when().post("/oidc/enable-introspection").then().body(equalTo("true"));
RestAssured.when().post("/cache/clear").then().body(equalTo("0"));

// verify introspection scopes are mapped to the StringPermissions
RestAssured.given().auth().oauth2(getOpaqueAccessTokenFromSimpleOidc())
.when().get("/tenant-opaque/tenant-oidc/api/user-permission")
.then()
.statusCode(200)
.body(equalTo("user"));
RestAssured.given().auth().oauth2(getOpaqueAccessTokenFromSimpleOidc())
.when().get("/tenant-opaque/tenant-oidc/api/admin-permission")
.then()
.statusCode(403);
}

private String getAccessToken(String userName, String clientId) {
return getAccessToken(userName, clientId, clientId);
}
Expand Down

0 comments on commit 8677d59

Please sign in to comment.