Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for OAuth2 Demonstrating Proof of Possession #45891

Merged
merged 1 commit into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,30 @@ If you set `quarkus.oidc.client-id`, but your endpoint does not require remote a
Quarkus `web-app` applications always require the `quarkus.oidc.client-id` property.
====

== Mutual TLS token binding
== Sender-constraining access tokens

=== Demonstrating Proof of Possession (DPoP)

https://datatracker.ietf.org/doc/html/rfc9449[RFC9449] describes a Demonstrating Proof of Possession (DPoP) mechanism for cryprographically binding an access token to the current client, preventing the access token loss and replay.

Single page application (SPA) public clients generate DPoP proof tokens and use them to acquire and submit access tokens which are cryptograhically bound to DPoP proofs.

Enabling DPoP support in Quarkus requires a single property.

For example:

[source,properties]
----
quarkus.oidc.auth-server-url=${your_oidc_provider_url}
quarkus.oidc.token.authorization-scheme=dpop <1>
----
<1> Require that the access tokens are provided using HTTP `Authorization DPoP` scheme value.

After accepting such tokens, Quarkus will go through the full https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs[DPoP token verification process].

Support for custom DPoP nonce providers may be offered in the future.

=== Mutual TLS token binding

https://datatracker.ietf.org/doc/html/rfc8705[RFC8705] describes a mechanism for binding access tokens to Mutual TLS (mTLS) client authentication certificates.
It requires that a client certificate's SHA256 thumbprint matches a JWT token or token introspection confirmation `x5t#S256` certificate thumbprint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;

import io.quarkus.runtime.annotations.ConfigDocMapKey;
import io.quarkus.runtime.annotations.ConfigRoot;
Expand Down Expand Up @@ -120,6 +121,14 @@ public interface KeycloakDevServicesConfig {
*/
Optional<String> startCommand();

/**
* Keycloak features.
* Use this property to enable one or more experimental Keycloak features.
* Note, if you also have to customize a Keycloak {@link #startCommand()}, you can use
* a `--features` option as part of the start command sequence, instead of configuring this property.
*/
Optional<Set<String>> features();

/**
* The name of the Keycloak realm.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ private static RunningDevService startContainer(
capturedDevServicesConfiguration.shared(),
capturedDevServicesConfiguration.javaOpts(),
capturedDevServicesConfiguration.startCommand(),
capturedDevServicesConfiguration.features(),
capturedDevServicesConfiguration.showLogs(),
capturedDevServicesConfiguration.containerMemoryLimit(),
errors);
Expand Down Expand Up @@ -480,14 +481,15 @@ private static class QuarkusOidcContainer extends GenericContainer<QuarkusOidcCo
private final boolean keycloakX;
private final List<RealmRepresentation> realmReps = new LinkedList<>();
private final Optional<String> startCommand;
private final Optional<Set<String>> features;
private final boolean showLogs;
private final MemorySize containerMemoryLimit;
private final List<String> errors;

public QuarkusOidcContainer(DockerImageName dockerImageName, OptionalInt fixedExposedPort, boolean useSharedNetwork,
List<String> realmPaths, Map<String, String> resources, String containerLabelValue,
boolean sharedContainer, Optional<String> javaOpts, Optional<String> startCommand, boolean showLogs,
MemorySize containerMemoryLimit, List<String> errors) {
boolean sharedContainer, Optional<String> javaOpts, Optional<String> startCommand,
Optional<Set<String>> features, boolean showLogs, MemorySize containerMemoryLimit, List<String> errors) {
super(dockerImageName);

this.useSharedNetwork = useSharedNetwork;
Expand All @@ -507,6 +509,7 @@ public QuarkusOidcContainer(DockerImageName dockerImageName, OptionalInt fixedEx

this.fixedExposedPort = fixedExposedPort;
this.startCommand = startCommand;
this.features = features;
this.showLogs = showLogs;
this.containerMemoryLimit = containerMemoryLimit;
this.errors = errors;
Expand Down Expand Up @@ -549,8 +552,12 @@ protected void configure() {
if (keycloakX) {
addEnv(KEYCLOAK_QUARKUS_ADMIN_PROP, KEYCLOAK_ADMIN_USER);
addEnv(KEYCLOAK_QUARKUS_ADMIN_PASSWORD_PROP, KEYCLOAK_ADMIN_PASSWORD);
withCommand(startCommand.orElse(KEYCLOAK_QUARKUS_START_CMD)
+ (useSharedNetwork ? " --hostname-backchannel-dynamic true" : ""));
String finalStartCommand = startCommand.orElse(KEYCLOAK_QUARKUS_START_CMD)
+ (useSharedNetwork ? " --hostname-backchannel-dynamic true" : "");
if (features.isPresent()) {
finalStartCommand += (" --features=" + features.get().stream().collect(Collectors.joining(",")));
}
withCommand(finalStartCommand);
addUpConfigResource();
if (isHttps()) {
addExposedPort(KEYCLOAK_HTTPS_PORT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,10 @@ public static String base64UrlDecode(String encodedContent) {
return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8);
}

public static String base64UrlEncode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

public static JsonObject decodeAsJsonObject(String encodedContent) {
try {
return new JsonObject(base64UrlDecode(encodedContent));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ public final class OidcConstants {
public static final String PASSWORD_GRANT_USERNAME = "username";
public static final String PASSWORD_GRANT_PASSWORD = "password";

public static final String TOKEN_TYPE_HEADER = "typ";
public static final String TOKEN_ALGORITHM_HEADER = "alg";
public static final String TOKEN_SCOPE = "scope";
public static final String GRANT_TYPE = "grant_type";

public static final String CLIENT_ID = "client_id";
public static final String CLIENT_SECRET = "client_secret";

public static final String BEARER_SCHEME = "Bearer";
public static final String DPOP_SCHEME = "DPoP";
public static final String BASIC_SCHEME = "Basic";

public static final String AUTHORIZATION_CODE = "authorization_code";
Expand Down Expand Up @@ -89,4 +92,10 @@ public final class OidcConstants {

public static final String CONFIRMATION_CLAIM = "cnf";
public static final String X509_SHA256_THUMBPRINT = "x5t#S256";
public static final String DPOP_TOKEN_TYPE = "dpop+jwt";
public static final String DPOP_JWK_SHA256_THUMBPRINT = "jkt";
public static final String DPOP_JWK_HEADER = "jwk";
public static final String DPOP_ACCESS_TOKEN_THUMBPRINT = "ath";
public static final String DPOP_HTTP_METHOD = "htm";
public static final String DPOP_HTTP_REQUEST_URI = "htu";
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.function.Function;

import javax.net.ssl.SSLPeerUnverifiedException;
Expand All @@ -14,12 +15,14 @@
import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.smallrye.mutiny.Uni;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;

public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
Expand All @@ -33,6 +36,7 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
if (token != null) {
try {
setCertificateThumbprint(context, oidcTenantConfig);
setDPopProof(context, oidcTenantConfig);
} catch (AuthenticationFailedException ex) {
return Uni.createFrom().failure(ex);
}
Expand All @@ -54,6 +58,67 @@ private static void setCertificateThumbprint(RoutingContext context, OidcTenantC
}
}

private static void setDPopProof(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
if (OidcConstants.DPOP_SCHEME.equals(oidcTenantConfig.token().authorizationScheme())) {

List<String> proofs = context.request().headers().getAll(OidcConstants.DPOP_SCHEME);
if (proofs == null || proofs.isEmpty()) {
LOG.warn("DPOP proof header must be present to verify the DPOP access token binding");
throw new AuthenticationFailedException();
}
if (proofs.size() != 1) {
LOG.warn("Only a single DPOP proof header is accepted");
throw new AuthenticationFailedException();
}
String proof = proofs.get(0);

// Initial proof check:
JsonObject proofJwtHeaders = OidcUtils.decodeJwtHeaders(proof);
JsonObject proofJwtClaims = OidcCommonUtils.decodeJwtContent(proof);

if (!OidcConstants.DPOP_TOKEN_TYPE.equals(proofJwtHeaders.getString(OidcConstants.TOKEN_TYPE_HEADER))) {
LOG.warn("Invalid DPOP proof token type ('typ') header");
throw new AuthenticationFailedException();
}

// Check HTTP method and request URI
String proofHttpMethod = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_METHOD);
if (proofHttpMethod == null) {
LOG.warn("DPOP proof HTTP method claim is missing");
throw new AuthenticationFailedException();
}

String httpMethod = context.request().method().name();
if (!httpMethod.equals(proofHttpMethod)) {
LOG.warnf("DPOP proof HTTP method claim %s does not match the request HTTP method %s", proofHttpMethod,
httpMethod);
throw new AuthenticationFailedException();
}

// Check HTTP request URI
String proofHttpRequestUri = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_REQUEST_URI);
if (proofHttpRequestUri == null) {
LOG.warn("DPOP proof HTTP request uri claim is missing");
throw new AuthenticationFailedException();
}

String httpRequestUri = context.request().absoluteURI();
int queryIndex = httpRequestUri.indexOf("?");
if (queryIndex > 0) {
httpRequestUri = httpRequestUri.substring(0, queryIndex);
}
if (!httpRequestUri.equals(proofHttpRequestUri)) {
LOG.warnf("DPOP proof HTTP request uri claim %s does not match the request HTTP uri %s", proofHttpRequestUri,
httpRequestUri);
throw new AuthenticationFailedException();
}

context.put(OidcUtils.DPOP_PROOF, proof);
context.put(OidcUtils.DPOP_PROOF_JWT_HEADERS, proofJwtHeaders);
context.put(OidcUtils.DPOP_PROOF_JWT_CLAIMS, proofJwtClaims);
}
}

private static Certificate getCertificate(RoutingContext context) {
try {
return context.request().sslSession().getPeerCertificates()[0];
Expand Down
Loading
Loading