Skip to content

Commit 8677d59

Browse files
authored
Merge pull request quarkusio#35923 from michalvavrik/feature/oidc-map-scopes-to-identity-permissions
Map OIDC scope attribute to the SecurityIdentity permissions
2 parents 6e04f9a + 386e333 commit 8677d59

File tree

9 files changed

+197
-7
lines changed

9 files changed

+197
-7
lines changed

docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ It is possible to use multiple expressions in the role definition.
400400
In development mode, it allows any authenticated user.
401401
<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`.
402402

403+
[[permission-annotation]]
403404
=== Permission annotation
404405

405406
Quarkus also provides the `io.quarkus.security.PermissionsAllowed` annotation, which authorizes any authenticated user with the given permission to access the resource.
@@ -728,3 +729,4 @@ CAUTION: Annotation permissions do not work with the custom xref:security-custom
728729
* xref:security-authentication-mechanisms.adoc#other-supported-authentication-mechanisms[Authentication mechanisms in Quarkus]
729730
* xref:security-basic-authentication.adoc[Basic authentication]
730731
* xref:security-basic-authentication-tutorial.adoc[Secure a Quarkus application with Basic authentication and Jakarta Persistence]
732+
* xref:security-oidc-bearer-token-authentication.adoc#token-scopes-and-security-identity-permissions[OpenID Connect Bearer Token Scopes And SecurityIdentity Permissions]

docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ The current tenant's discovered link:https://openid.net/specs/openid-connect-dis
9696

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

99+
[[token-claims-and-security-identity-roles]]
99100
=== Token Claims And SecurityIdentity Roles
100101

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

113114
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].
114115

116+
[[token-scopes-and-security-identity-permissions]]
117+
=== Token scopes And SecurityIdentity permissions
118+
119+
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.
120+
121+
[source, java]
122+
----
123+
import jakarta.inject.Inject;
124+
import jakarta.ws.rs.GET;
125+
import jakarta.ws.rs.Path;
126+
127+
import org.eclipse.microprofile.jwt.Claims;
128+
import org.eclipse.microprofile.jwt.JsonWebToken;
129+
130+
import io.quarkus.security.PermissionsAllowed;
131+
132+
@Path("/service")
133+
public class ProtectedResource {
134+
135+
@Inject
136+
JsonWebToken accessToken;
137+
138+
@PermissionsAllowed("email") <1>
139+
@GET
140+
@Path("/email")
141+
public Boolean isUserEmailAddressVerifiedByUser() {
142+
return accessToken.getClaim(Claims.email_verified.name());
143+
}
144+
145+
@PermissionsAllowed("orders_read") <2>
146+
@GET
147+
@Path("/order")
148+
public List<Order> listOrders() {
149+
return List.of(new Order(1));
150+
}
151+
152+
}
153+
----
154+
<1> Only requests with OpenID Connect scope `email` are going to be granted access.
155+
<2> The read access is limited to the client requests with scope `orders_read`.
156+
157+
Please refer to the Permission annotation section of the xref:security-authorize-web-endpoints-reference.adoc#permission-annotation[Authorization of web endpoints]
158+
guide for more information about the `io.quarkus.security.PermissionsAllowed` annotation.
159+
115160
[[token-verification-introspection]]
116161
=== Token Verification And Introspection
117162

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ && tokenAutoRefreshPrepared(result, vertxContext, resolvedContext.oidcConfig)) {
330330
Set<String> scopes = result.introspectionResult.getScopes();
331331
if (scopes != null) {
332332
builder.addRoles(scopes);
333+
OidcUtils.addTokenScopesAsPermissions(builder, scopes);
333334
}
334335
}
335336
builder.setPrincipal(new Principal() {
@@ -339,8 +340,9 @@ public String getName() {
339340
}
340341
});
341342
if (userInfo != null) {
342-
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig,
343-
new JsonObject(userInfo.getJsonObject().toString()));
343+
var rolesJson = new JsonObject(userInfo.getJsonObject().toString());
344+
OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig, rolesJson);
345+
OidcUtils.setSecurityIdentityPermissions(builder, resolvedContext.oidcConfig, rolesJson);
344346
}
345347
OidcUtils.setBlockingApiAttribute(builder, vertxContext);
346348
OidcUtils.setTenantIdAttribute(builder, resolvedContext.oidcConfig);

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
package io.quarkus.oidc.runtime;
22

3+
import static io.quarkus.oidc.common.runtime.OidcConstants.TOKEN_SCOPE;
4+
35
import java.nio.charset.StandardCharsets;
46
import java.security.Key;
57
import java.security.MessageDigest;
68
import java.security.NoSuchAlgorithmException;
9+
import java.security.Permission;
710
import java.util.ArrayList;
811
import java.util.Arrays;
912
import java.util.Base64;
13+
import java.util.Collection;
1014
import java.util.Collections;
1115
import java.util.LinkedList;
1216
import java.util.List;
1317
import java.util.StringTokenizer;
1418
import java.util.function.Consumer;
19+
import java.util.function.Function;
1520
import java.util.regex.Pattern;
1621

1722
import javax.crypto.SecretKey;
@@ -37,6 +42,7 @@
3742
import io.quarkus.oidc.UserInfo;
3843
import io.quarkus.oidc.runtime.providers.KnownOidcProviders;
3944
import io.quarkus.security.AuthenticationFailedException;
45+
import io.quarkus.security.StringPermission;
4046
import io.quarkus.security.credential.TokenCredential;
4147
import io.quarkus.security.identity.AuthenticationRequestContext;
4248
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
@@ -261,6 +267,7 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(
261267
builder.setPrincipal(jwtPrincipal);
262268
setRoutingContextAttribute(builder, vertxContext);
263269
setSecurityIdentityRoles(builder, config, rolesJson);
270+
setSecurityIdentityPermissions(builder, config, rolesJson);
264271
setSecurityIdentityUserInfo(builder, userInfo);
265272
setSecurityIdentityIntrospection(builder, introspectionResult);
266273
setSecurityIdentityConfigMetadata(builder, resolvedContext);
@@ -269,6 +276,41 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(
269276
return builder.build();
270277
}
271278

279+
static void setSecurityIdentityPermissions(QuarkusSecurityIdentity.Builder builder, OidcTenantConfig config,
280+
JsonObject permissionsJson) {
281+
addTokenScopesAsPermissions(builder, findClaimWithRoles(config.getRoles(), TOKEN_SCOPE, permissionsJson));
282+
}
283+
284+
static void addTokenScopesAsPermissions(Builder builder, Collection<String> scopes) {
285+
if (!scopes.isEmpty()) {
286+
builder.addPermissionChecker(new Function<Permission, Uni<Boolean>>() {
287+
288+
private final Permission[] permissions = transformScopesToPermissions(scopes);
289+
290+
@Override
291+
public Uni<Boolean> apply(Permission requiredPermission) {
292+
for (Permission possessedPermission : permissions) {
293+
if (possessedPermission.implies(requiredPermission)) {
294+
// access granted
295+
return Uni.createFrom().item(Boolean.TRUE);
296+
}
297+
}
298+
// access denied
299+
return Uni.createFrom().item(Boolean.FALSE);
300+
}
301+
});
302+
}
303+
}
304+
305+
private static Permission[] transformScopesToPermissions(Collection<String> scopes) {
306+
final Permission[] permissions = new Permission[scopes.size()];
307+
int i = 0;
308+
for (String scope : scopes) {
309+
permissions[i++] = new StringPermission(scope);
310+
}
311+
return permissions;
312+
}
313+
272314
public static void setSecurityIdentityRoles(QuarkusSecurityIdentity.Builder builder, OidcTenantConfig config,
273315
JsonObject rolesJson) {
274316
String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null;

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,18 @@ public OidcTenantConfig get() {
5050
config.setTenantId("tenant-oidc");
5151
String uri = context.request().absoluteURI();
5252
// authServerUri points to the JAX-RS `OidcResource`, root path is `/oidc`
53-
String authServerUri = path.contains("tenant-opaque")
54-
? uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc")
55-
: uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
53+
final String authServerUri;
54+
if (path.contains("tenant-opaque")) {
55+
if (path.endsWith("/tenant-opaque/tenant-oidc/api/user")) {
56+
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc");
57+
} else if (path.endsWith("/tenant-opaque/tenant-oidc/api/user-permission")) {
58+
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/user-permission", "/oidc");
59+
} else {
60+
authServerUri = uri.replace("/tenant-opaque/tenant-oidc/api/admin-permission", "/oidc");
61+
}
62+
} else {
63+
authServerUri = uri.replace("/tenant/tenant-oidc/api/user", "/oidc");
64+
}
5665
config.setAuthServerUrl(authServerUri);
5766
config.setClientId("client");
5867
config.setAllowTokenIntrospectionCache(false);

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantHybridResource.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import org.eclipse.microprofile.jwt.JsonWebToken;
99

1010
import io.quarkus.oidc.IdToken;
11+
import io.quarkus.oidc.common.runtime.OidcConstants;
12+
import io.quarkus.security.PermissionsAllowed;
1113

1214
@Path("/tenants")
1315
public class TenantHybridResource {
@@ -23,4 +25,19 @@ public class TenantHybridResource {
2325
public String userNameService() {
2426
return idToken.getName() != null ? (idToken.getName() + ":web-app") : (accessToken.getName() + ":service");
2527
}
28+
29+
@GET
30+
@Path("/{tenant-hybrid}/api/mp-scope")
31+
@PermissionsAllowed("microprofile-jwt")
32+
public String microProfileScopeService() {
33+
return accessToken.getClaim(OidcConstants.TOKEN_SCOPE);
34+
}
35+
36+
@GET
37+
@Path("/{tenant-hybrid}/api/non-existent-scope")
38+
@PermissionsAllowed("microprofile-jwt")
39+
@PermissionsAllowed("nonexistent-scope")
40+
public String nonExistentScopeService() {
41+
return accessToken.getClaim(OidcConstants.TOKEN_SCOPE);
42+
}
2643
}

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io.quarkus.oidc.OIDCException;
1010
import io.quarkus.oidc.TokenIntrospection;
1111
import io.quarkus.security.Authenticated;
12+
import io.quarkus.security.PermissionsAllowed;
1213
import io.quarkus.security.identity.SecurityIdentity;
1314

1415
@Path("/tenant-opaque")
@@ -42,6 +43,20 @@ public String userName() {
4243
+ ":" + tokenIntrospection.getString("email");
4344
}
4445

46+
@GET
47+
@PermissionsAllowed("user")
48+
@Path("tenant-oidc/api/user-permission")
49+
public String userPermission() {
50+
return "user";
51+
}
52+
53+
@GET
54+
@PermissionsAllowed("admin")
55+
@Path("tenant-oidc/api/admin-permission")
56+
public String adminPermission() {
57+
return "admin";
58+
}
59+
4560
@GET
4661
@Path("tenant-oidc-no-opaque-token/api/user")
4762
public String userNameNoOpaqueToken() {

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package io.quarkus.it.keycloak;
22

3+
import java.util.Arrays;
4+
import java.util.stream.Collectors;
5+
36
import jakarta.annotation.security.RolesAllowed;
47
import jakarta.inject.Inject;
58
import jakarta.ws.rs.GET;
@@ -19,6 +22,8 @@
1922
import io.quarkus.oidc.UserInfo;
2023
import io.quarkus.oidc.client.OidcClientConfig;
2124
import io.quarkus.oidc.client.OidcClients;
25+
import io.quarkus.oidc.common.runtime.OidcConstants;
26+
import io.quarkus.security.PermissionsAllowed;
2227
import io.quarkus.security.identity.SecurityIdentity;
2328
import io.vertx.ext.web.RoutingContext;
2429

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

169+
@GET
170+
@Path("webapp2-scope-permissions")
171+
@PermissionsAllowed({ "openid", "email", "profile" })
172+
public String scopePermissionsWebApp2(@PathParam("tenant") String tenant) {
173+
if (!tenant.equals("tenant-web-app2")) {
174+
throw new OIDCException("Wrong tenant");
175+
}
176+
return Arrays
177+
.stream(accessToken.<String> getClaim(OidcConstants.TOKEN_SCOPE).split(" "))
178+
.sorted(String::compareTo)
179+
.collect(Collectors.joining(" "));
180+
}
181+
164182
private String getNameWebAppType(String name,
165183
String idTokenNameClaim,
166184
String idTokenNameClaimNotExpected) {

integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,18 @@ public void testJavaScriptRequest() throws IOException, InterruptedException {
106106

107107
@Test
108108
public void testResolveTenantIdentifierWebApp2() throws IOException {
109+
testTenantWebApp2("webapp2", "tenant-web-app2:alice");
110+
}
111+
112+
@Test
113+
public void testScopePermissionsFromAccessToken() throws IOException {
114+
// source of permissions is access token
115+
testTenantWebApp2("webapp2-scope-permissions", "email openid profile");
116+
}
117+
118+
private void testTenantWebApp2(String webApp2SubPath, String expectedResult) throws IOException {
109119
try (final WebClient webClient = createWebClient()) {
110-
HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/webapp2");
120+
HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/" + webApp2SubPath);
111121
// State cookie is available but there must be no saved path parameter
112122
// as the tenant-web-app configuration does not set a redirect-path property
113123
assertNull(getStateCookieSavedPath(webClient, "tenant-web-app2"));
@@ -116,7 +126,7 @@ public void testResolveTenantIdentifierWebApp2() throws IOException {
116126
loginForm.getInputByName("username").setValueAttribute("alice");
117127
loginForm.getInputByName("password").setValueAttribute("alice");
118128
page = loginForm.getInputByName("login").click();
119-
assertEquals("tenant-web-app2:alice", page.getBody().asNormalizedText());
129+
assertEquals(expectedResult, page.getBody().asNormalizedText());
120130
webClient.getCookieManager().clearCookies();
121131
}
122132
}
@@ -235,6 +245,19 @@ public void testHybridWebAppService() throws IOException {
235245
.body(equalTo("alice:service"));
236246
}
237247

248+
@Test
249+
public void testDefaultClientScopeAsPermission() {
250+
RestAssured.given().auth().oauth2(getAccessToken("alice", "hybrid"))
251+
.when().get("/tenants/tenant-hybrid-webapp-service/api/mp-scope")
252+
.then()
253+
.statusCode(200)
254+
.body(equalTo("microprofile-jwt"));
255+
RestAssured.given().auth().oauth2(getAccessToken("alice", "hybrid"))
256+
.when().get("/tenants/tenant-hybrid-webapp-service/api/non-existent-scope")
257+
.then()
258+
.statusCode(403);
259+
}
260+
238261
@Test
239262
public void testResolveTenantIdentifierWebAppNoDiscovery() throws IOException {
240263
try (final WebClient webClient = createWebClient()) {
@@ -636,6 +659,23 @@ public void testRequiredClaimFail() {
636659
.statusCode(401);
637660
}
638661

662+
@Test
663+
public void testOpaqueTokenScopePermission() {
664+
RestAssured.when().post("/oidc/enable-introspection").then().body(equalTo("true"));
665+
RestAssured.when().post("/cache/clear").then().body(equalTo("0"));
666+
667+
// verify introspection scopes are mapped to the StringPermissions
668+
RestAssured.given().auth().oauth2(getOpaqueAccessTokenFromSimpleOidc())
669+
.when().get("/tenant-opaque/tenant-oidc/api/user-permission")
670+
.then()
671+
.statusCode(200)
672+
.body(equalTo("user"));
673+
RestAssured.given().auth().oauth2(getOpaqueAccessTokenFromSimpleOidc())
674+
.when().get("/tenant-opaque/tenant-oidc/api/admin-permission")
675+
.then()
676+
.statusCode(403);
677+
}
678+
639679
private String getAccessToken(String userName, String clientId) {
640680
return getAccessToken(userName, clientId, clientId);
641681
}

0 commit comments

Comments
 (0)