Skip to content

Commit 735a043

Browse files
committed
feat(oidc): add step up auth support for bearer token
1 parent cc8f7e6 commit 735a043

File tree

25 files changed

+1317
-37
lines changed

25 files changed

+1317
-37
lines changed

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

+107
Original file line numberDiff line numberDiff line change
@@ -1604,6 +1604,113 @@ public class OidcStartup {
16041604
For more complex setup involving multiple tenants please see the xref:security-openid-connect-multitenancy.adoc#programmatic-startup[Programmatic OIDC start-up for multitenant application]
16051605
section of the OpenID Connect Multi-Tenancy guide.
16061606

1607+
== Step Up Authentication
1608+
1609+
The `io.quarkus.oidc.AuthenticationContext` annotation can be used to list one or more Authentication Context Class Reference (ACR) values to enforce a required authentication level for the Jakarta REST resource classes and methods.
1610+
The https://datatracker.ietf.org/doc/rfc9470/[OAuth 2.0 Step Up Authentication Challenge Protocol] introduces a mechanism for resource servers to request stronger authentication methods when the token does not have expected Authentication Context Class Reference (ACR) values.
1611+
Consider the following example:
1612+
1613+
[source,java]
1614+
----
1615+
package io.quarkus.it.oidc;
1616+
1617+
import io.quarkus.oidc.AuthenticationContext;
1618+
import io.quarkus.oidc.BearerTokenAuthentication;
1619+
import jakarta.ws.rs.GET;
1620+
import jakarta.ws.rs.Path;
1621+
1622+
@BearerTokenAuthentication
1623+
@Path("/")
1624+
public class GreetingsResource {
1625+
1626+
@Path("hello")
1627+
@AuthenticationContext("myACR") <1>
1628+
@GET
1629+
public String hello() {
1630+
return "hello";
1631+
}
1632+
1633+
@Path("hi")
1634+
@AuthenticationContext(value = "myACR", maxAge = "PT120m") <2>
1635+
@GET
1636+
public String hi() {
1637+
return "hi";
1638+
}
1639+
}
1640+
----
1641+
<1> Bearer access token must have an `acr` claim with the `myACR` ACR value.
1642+
<2> Bearer access token must have an `acr` claim with the `myACR` ACR value and be in use for no longer than 120 minutes since the authentication time.
1643+
1644+
[source,properties]
1645+
----
1646+
quarkus.http.auth.proactive=false <1>
1647+
----
1648+
<1> Disable proactive authentication so that the `@AuthenticationContext` annotation can be matched with the endpoint before Quarkus authenticates incoming requests.
1649+
1650+
If the bearer access token claim `acr` does not contain `myACR`, Quarkus returns an authentication requirements challenge indicating required `acr_values`:
1651+
1652+
----
1653+
HTTP/1.1 401 Unauthorized
1654+
WWW-Authenticate: Bearer error="insufficient_user_authentication",
1655+
error_description="A different authentication level is required",
1656+
acr_values="myACR"
1657+
----
1658+
1659+
When a client such as Single-page application (SPA) receives a challenge with the `insufficient_user_authentication` error code, it must parse `acr_values`, request a new user login which must meet the `acr_values` constraints, and use a new access token to access Quarkus.
1660+
1661+
[NOTE]
1662+
====
1663+
The `io.quarkus.oidc.AuthenticationContext` annotation can also be used to enforce required authentication level for a WebSockets Next server endpoint.
1664+
The annotation must be placed on the endpoint class, because the `SecurityIdentity` is created before the HTTP connection is upgraded to a WebSocket connection.
1665+
For more information about the HTTP upgrade security, see the xref:websockets-next-reference.adoc#secure-http-upgrade[Secure HTTP upgrade] section of the Quarkus "WebSockets Next reference" guide.
1666+
====
1667+
1668+
It is also possible to enforce the required authentication level for an OIDC tenant:
1669+
1670+
[source,properties]
1671+
----
1672+
quarkus.oidc.hr.token.required-claims.acr=myACR
1673+
----
1674+
1675+
Or, if you need more flexibility, write a <<jose4j-validator>>:
1676+
1677+
[source,java]
1678+
----
1679+
package io.quarkus.it.oidc;
1680+
1681+
import java.util.Map;
1682+
1683+
import jakarta.enterprise.context.ApplicationScoped;
1684+
1685+
import org.jose4j.jwt.MalformedClaimException;
1686+
import org.jose4j.jwt.consumer.JwtContext;
1687+
import org.jose4j.jwt.consumer.Validator;
1688+
1689+
import io.quarkus.arc.Unremovable;
1690+
import io.quarkus.oidc.TenantFeature;
1691+
import io.quarkus.oidc.common.runtime.OidcConstants;
1692+
import io.quarkus.security.AuthenticationFailedException;
1693+
1694+
@Unremovable
1695+
@ApplicationScoped
1696+
@TenantFeature("hr")
1697+
public class AcrValueValidator implements Validator {
1698+
1699+
@Override
1700+
public String validate(JwtContext jwtContext) throws MalformedClaimException {
1701+
var jwtClaims = jwtContext.getJwtClaims();
1702+
if (jwtClaims.hasClaim("acr")) {
1703+
var acrClaim = jwtClaims.getStringListClaimValue("acr");
1704+
if (acrClaim.contains("myACR") && acrClaim.contains("yourACR")) {
1705+
return null;
1706+
}
1707+
}
1708+
String requiredAcrValues = "myACR,yourACR";
1709+
throw new AuthenticationFailedException(Map.of(OidcConstants.ACR_VALUES, requiredAcrValues));
1710+
}
1711+
}
1712+
----
1713+
16071714
== References
16081715

16091716
* xref:security-oidc-configuration-properties-reference.adoc[OIDC configuration properties]

extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java

+4
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,8 @@ public final class OidcConstants {
9898
public static final String DPOP_ACCESS_TOKEN_THUMBPRINT = "ath";
9999
public static final String DPOP_HTTP_METHOD = "htm";
100100
public static final String DPOP_HTTP_REQUEST_URI = "htu";
101+
102+
public static final String ACR = "acr";
103+
public static final String ACR_VALUES = "acr_values";
104+
public static final String MAX_AGE = "max_age";
101105
}

extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java

+86-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import static io.quarkus.arc.processor.DotNames.NAMED;
77
import static io.quarkus.oidc.common.runtime.OidcConstants.BEARER_SCHEME;
88
import static io.quarkus.oidc.common.runtime.OidcConstants.CODE_FLOW_CODE;
9+
import static io.quarkus.oidc.runtime.OidcRecorder.ACR_VALUES_TO_MAX_AGE_SEPARATOR;
910
import static io.quarkus.oidc.runtime.OidcUtils.DEFAULT_TENANT_ID;
11+
import static io.quarkus.security.spi.ClassSecurityAnnotationBuildItem.useClassLevelSecurity;
12+
import static io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBindingBuildItem.toTargetName;
13+
import static io.quarkus.vertx.http.deployment.HttpSecurityProcessor.collectAnnotatedClasses;
14+
import static io.quarkus.vertx.http.deployment.HttpSecurityProcessor.collectClassMethodsWithoutRbacAnnotation;
15+
import static io.quarkus.vertx.http.deployment.HttpSecurityProcessor.collectMethodsWithoutRbacAnnotation;
1016
import static org.jboss.jandex.AnnotationTarget.Kind.CLASS;
1117
import static org.jboss.jandex.AnnotationTarget.Kind.METHOD;
1218

@@ -15,6 +21,7 @@
1521
import java.util.Optional;
1622
import java.util.Set;
1723
import java.util.function.BooleanSupplier;
24+
import java.util.function.Predicate;
1825

1926
import jakarta.enterprise.context.RequestScoped;
2027
import jakarta.inject.Singleton;
@@ -25,8 +32,10 @@
2532
import org.jboss.jandex.AnnotationInstance;
2633
import org.jboss.jandex.AnnotationTarget;
2734
import org.jboss.jandex.AnnotationValue;
35+
import org.jboss.jandex.ClassInfo;
2836
import org.jboss.jandex.ClassType;
2937
import org.jboss.jandex.DotName;
38+
import org.jboss.jandex.MethodInfo;
3039
import org.jboss.jandex.ParameterizedType;
3140
import org.jboss.jandex.Type;
3241
import org.jboss.logging.Logger;
@@ -63,6 +72,7 @@
6372
import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem;
6473
import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem;
6574
import io.quarkus.deployment.builditem.SystemPropertyBuildItem;
75+
import io.quarkus.oidc.AuthenticationContext;
6676
import io.quarkus.oidc.AuthorizationCodeFlow;
6777
import io.quarkus.oidc.BearerTokenAuthentication;
6878
import io.quarkus.oidc.IdToken;
@@ -90,11 +100,17 @@
90100
import io.quarkus.oidc.runtime.OidcUtils;
91101
import io.quarkus.oidc.runtime.TenantConfigBean;
92102
import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer;
103+
import io.quarkus.runtime.configuration.ConfigurationException;
104+
import io.quarkus.security.Authenticated;
93105
import io.quarkus.security.runtime.SecurityConfig;
106+
import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem;
107+
import io.quarkus.security.spi.ClassSecurityAnnotationBuildItem;
108+
import io.quarkus.security.spi.RegisterClassSecurityCheckBuildItem;
94109
import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem;
95110
import io.quarkus.vertx.core.deployment.CoreVertxBuildItem;
96111
import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBindingBuildItem;
97112
import io.quarkus.vertx.http.deployment.HttpAuthMechanismAnnotationBuildItem;
113+
import io.quarkus.vertx.http.deployment.HttpSecurityUtils;
98114
import io.quarkus.vertx.http.deployment.PreRouterFinalizationBuildItem;
99115
import io.quarkus.vertx.http.deployment.SecurityInformationBuildItem;
100116
import io.quarkus.vertx.http.runtime.VertxHttpBuildTimeConfig;
@@ -112,6 +128,7 @@ public class OidcBuildStep {
112128
DotNames.INJECTABLE_INSTANCE);
113129
private static final DotName TENANT_NAME = DotName.createSimple(Tenant.class);
114130
private static final DotName TENANT_FEATURE_NAME = DotName.createSimple(TenantFeature.class);
131+
private static final DotName AUTHENTICATION_CONTEXT_NAME = DotName.createSimple(AuthenticationContext.class);
115132
private static final DotName TENANT_IDENTITY_PROVIDER_NAME = DotName.createSimple(TenantIdentityProvider.class);
116133
private static final Logger LOG = Logger.getLogger(OidcBuildStep.class);
117134
private static final DotName USER_INFO_NAME = DotName.createSimple(UserInfo.class);
@@ -348,7 +365,8 @@ public void registerTenantResolverInterceptor(Capabilities capabilities, OidcRec
348365
BuildProducer<EagerSecurityInterceptorBindingBuildItem> bindingProducer,
349366
BuildProducer<SystemPropertyBuildItem> systemPropertyProducer) {
350367
if (!httpBuildTimeConfig.auth().proactive()
351-
&& (capabilities.isPresent(Capability.RESTEASY_REACTIVE) || capabilities.isPresent(Capability.RESTEASY))) {
368+
&& (capabilities.isPresent(Capability.RESTEASY_REACTIVE) || capabilities.isPresent(Capability.RESTEASY)
369+
|| capabilities.isPresent(Capability.WEBSOCKETS_NEXT))) {
352370
boolean foundTenantResolver = combinedIndexBuildItem
353371
.getIndex()
354372
.getAnnotations(TENANT_NAME)
@@ -399,6 +417,73 @@ RunTimeConfigBuilderBuildItem useOidcTenantDefaultIdConfigBuilder() {
399417
return new RunTimeConfigBuilderBuildItem(OidcTenantDefaultIdConfigBuilder.class);
400418
}
401419

420+
@BuildStep
421+
@Record(ExecutionTime.STATIC_INIT)
422+
public void registerAuthenticationContextInterceptor(Capabilities capabilities, OidcRecorder recorder,
423+
VertxHttpBuildTimeConfig httpBuildTimeConfig, CombinedIndexBuildItem combinedIndexBuildItem,
424+
BuildProducer<RegisterClassSecurityCheckBuildItem> registerClassSecurityCheckProducer,
425+
List<ClassSecurityAnnotationBuildItem> classSecurityAnnotations,
426+
BuildProducer<AdditionalSecuredMethodsBuildItem> additionalSecuredMethodsProducer,
427+
BuildProducer<EagerSecurityInterceptorBindingBuildItem> bindingProducer) {
428+
var authCtxAnnotations = combinedIndexBuildItem.getIndex().getAnnotations(AUTHENTICATION_CONTEXT_NAME);
429+
if (authCtxAnnotations.isEmpty() || !areEagerSecInterceptorsSupported(capabilities, httpBuildTimeConfig)) {
430+
return;
431+
}
432+
bindingProducer.produce(new EagerSecurityInterceptorBindingBuildItem(recorder.authenticationContextInterceptorCreator(),
433+
ai -> {
434+
AnnotationValue maxAgeAnnotationValue = ai.value("maxAge");
435+
String maxAge = maxAgeAnnotationValue == null ? "" : maxAgeAnnotationValue.asString();
436+
437+
String acrValues = "";
438+
AnnotationValue annotationValue = ai.value();
439+
String[] annotationValues = annotationValue == null ? null : annotationValue.asStringArray();
440+
if (annotationValues == null || annotationValues.length == 0) {
441+
// no acr values and no max age
442+
throw new ConfigurationException("Annotation '" + AUTHENTICATION_CONTEXT_NAME + "' placed on '"
443+
+ toTargetName(ai.target()) + "' specifies no 'acr' value");
444+
} else {
445+
acrValues = String.join(",", annotationValues);
446+
}
447+
448+
return acrValues + ACR_VALUES_TO_MAX_AGE_SEPARATOR + maxAge;
449+
}, true, AUTHENTICATION_CONTEXT_NAME));
450+
451+
// @AuthenticationContext -> authentication required
452+
// register @Authenticated for annotated methods
453+
Set<MethodInfo> annotatedMethods = collectMethodsWithoutRbacAnnotation(authCtxAnnotations
454+
.stream()
455+
.map(AnnotationInstance::target)
456+
.filter(at -> at.kind() == METHOD)
457+
.map(AnnotationTarget::asMethod)
458+
.toList());
459+
additionalSecuredMethodsProducer
460+
.produce(new AdditionalSecuredMethodsBuildItem(annotatedMethods, Optional.of(List.of("**"))));
461+
// method-level security; this registers @Authenticated if no RBAC is explicitly declared
462+
Predicate<ClassInfo> useClassLevelSecurity = useClassLevelSecurity(classSecurityAnnotations);
463+
Set<MethodInfo> annotatedClassMethods = collectClassMethodsWithoutRbacAnnotation(
464+
collectAnnotatedClasses(authCtxAnnotations, Predicate.not(useClassLevelSecurity)));
465+
additionalSecuredMethodsProducer
466+
.produce(new AdditionalSecuredMethodsBuildItem(annotatedClassMethods, Optional.of(List.of("**"))));
467+
// class-level security; this registers @Authenticated if no RBAC is explicitly declared
468+
collectAnnotatedClasses(authCtxAnnotations, useClassLevelSecurity).stream()
469+
.filter(Predicate.not(HttpSecurityUtils::hasSecurityAnnotation))
470+
.forEach(c -> registerClassSecurityCheckProducer.produce(
471+
new RegisterClassSecurityCheckBuildItem(c.name(), AnnotationInstance
472+
.builder(Authenticated.class).buildWithTarget(c))));
473+
}
474+
475+
private static boolean areEagerSecInterceptorsSupported(Capabilities capabilities,
476+
VertxHttpBuildTimeConfig httpBuildTimeConfig) {
477+
if (httpBuildTimeConfig.auth().proactive()) {
478+
throw new RuntimeException("The '%s' annotation is only supported when proactive authentication is disabled"
479+
.formatted(AUTHENTICATION_CONTEXT_NAME));
480+
} else if (capabilities.isMissing(Capability.WEBSOCKETS_NEXT) && capabilities.isMissing(Capability.RESTEASY_REACTIVE)
481+
&& capabilities.isMissing(Capability.RESTEASY)) {
482+
throw new RuntimeException("The '%s' can only be used on Jakarta REST or WebSockets Next endpoints");
483+
}
484+
return true;
485+
}
486+
402487
private static boolean isInjected(BeanRegistrationPhaseBuildItem beanRegistrationPhaseBuildItem, DotName requiredType,
403488
DotName withoutQualifier) {
404489
for (InjectionPointInfo injectionPoint : beanRegistrationPhaseBuildItem.getInjectionPoints()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.quarkus.oidc.test;
2+
3+
import jakarta.ws.rs.GET;
4+
import jakarta.ws.rs.Path;
5+
6+
import org.jboss.shrinkwrap.api.asset.StringAsset;
7+
import org.junit.jupiter.api.Assertions;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.extension.RegisterExtension;
10+
11+
import io.quarkus.oidc.AuthenticationContext;
12+
import io.quarkus.runtime.util.ExceptionUtil;
13+
import io.quarkus.test.QuarkusUnitTest;
14+
15+
public class EmptyAuthenticationContextValidationFailureTest {
16+
17+
@RegisterExtension
18+
static final QuarkusUnitTest test = new QuarkusUnitTest()
19+
.withApplicationRoot(jar -> jar
20+
// starting the dev service would be a waste
21+
.addClass(StepUpAuthResource.class)
22+
.addAsResource(new StringAsset("""
23+
quarkus.devservices.enabled=false
24+
quarkus.http.auth.proactive=false
25+
"""), "application.properties"))
26+
.assertException(t -> {
27+
Throwable rootCause = ExceptionUtil.getRootCause(t);
28+
Assertions.assertNotNull(rootCause);
29+
String message = rootCause.getMessage();
30+
Assertions.assertNotNull(message);
31+
Assertions.assertTrue(message.contains("io.quarkus.oidc.AuthenticationContext"), message);
32+
Assertions.assertTrue(message.contains("specifies no 'acr' value"), message);
33+
});
34+
35+
@Test
36+
public void test() {
37+
Assertions.fail("Validation should fail");
38+
}
39+
40+
@AuthenticationContext({})
41+
@Path("step-up-auth")
42+
public static class StepUpAuthResource {
43+
44+
@GET
45+
public String stepUpAuth() {
46+
return "step-up-auth";
47+
}
48+
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package io.quarkus.oidc.test;
2+
3+
import jakarta.ws.rs.GET;
4+
import jakarta.ws.rs.Path;
5+
6+
import org.jboss.shrinkwrap.api.asset.StringAsset;
7+
import org.junit.jupiter.api.Assertions;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.extension.RegisterExtension;
10+
11+
import io.quarkus.oidc.AuthenticationContext;
12+
import io.quarkus.runtime.util.ExceptionUtil;
13+
import io.quarkus.test.QuarkusUnitTest;
14+
15+
public class ProactiveAuthenticationContextValidationFailureTest {
16+
17+
@RegisterExtension
18+
static final QuarkusUnitTest test = new QuarkusUnitTest()
19+
.withApplicationRoot(jar -> jar
20+
// starting the dev service would be a waste
21+
.addClass(StepUpAuthResource.class)
22+
.addAsResource(new StringAsset("quarkus.devservices.enabled=false"), "application.properties"))
23+
.assertException(t -> {
24+
Throwable rootCause = ExceptionUtil.getRootCause(t);
25+
Assertions.assertNotNull(rootCause);
26+
String message = rootCause.getMessage();
27+
Assertions.assertNotNull(message);
28+
Assertions.assertTrue(message.contains("proactive authentication is disabled"), message);
29+
});
30+
31+
@Test
32+
public void test() {
33+
Assertions.fail("Validation should fail");
34+
}
35+
36+
@AuthenticationContext("ignored")
37+
@Path("step-up-auth")
38+
public static class StepUpAuthResource {
39+
40+
@GET
41+
public String stepUpAuth() {
42+
return "step-up-auth";
43+
}
44+
45+
}
46+
}

0 commit comments

Comments
 (0)