[java-idp-oidc] branch main updated: JOIDC-201 - Support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)

Henri Mikkonen henri.mikkonen at iki.fi
Fri Aug 9 06:58:41 UTC 2024


This is an automated email from the git hooks/post-receive script.

hjmikkon pushed a commit to branch main
in repository java-idp-oidc.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-oidc.git;a=commit;h=cf08bcd5558a87ce51f3265978b7dfa2a41e862f

The following commit(s) were added to refs/heads/main by this push:
     new cf08bcd5 JOIDC-201 - Support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)
cf08bcd5 is described below

commit cf08bcd5558a87ce51f3265978b7dfa2a41e862f
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Fri Aug 9 09:58:22 2024 +0300

    JOIDC-201 - Support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)
    
    https://shibboleth.atlassian.net/browse/JOIDC-201
    
    Bind the DPoP key to refresh token of public clients as mandated by section 5 of the spec.
    Confidential clients may rotate the key as they authenticate to the token endpoint with the
    refresh token anyway.
---
 .../plugin/oidc/op/profile/impl/ValidateGrant.java |  31 +++++-
 .../plugin/oidc/op/profile/flow/TokenFlowTest.java | 121 +++++++++++++++++++++
 2 files changed, 151 insertions(+), 1 deletion(-)

diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
index f1916ad5..3c2c087c 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
@@ -36,7 +36,9 @@ import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
 import com.nimbusds.oauth2.sdk.AuthorizationGrant;
 import com.nimbusds.oauth2.sdk.GrantType;
 import com.nimbusds.oauth2.sdk.RefreshTokenGrant;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
 import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientInformation;
 
 import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
 import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.DefaultDPoPProofThumbprintLookupFunction;
@@ -46,6 +48,7 @@ import net.shibboleth.idp.plugin.oidc.op.token.support.AuthorizeCodeClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.RefreshTokenClaimsSet;
 import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
 import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.metadata.context.OIDCMetadataContext;
 import net.shibboleth.oidc.profile.config.logic.RefreshTokensEnabledPredicate;
 import net.shibboleth.oidc.profile.config.navigate.RefreshTokenChainLifetimeLookupFunction;
 import net.shibboleth.oidc.profile.config.navigate.RevocationLifetimeLookupFunction;
@@ -396,11 +399,18 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
         final OIDCAuthenticationResponseContext oidcResponseContext = getOidcResponseContext();
         assert oidcResponseContext != null;
         oidcResponseContext.setAuthorizationGrantClaimsSet(tokenClaimsSet);
-        final String claimsSetThumbprint = tokenClaimsSet.getDpopProofJwkThumbprint();
+        final String claimsSetThumbprint = GrantType.REFRESH_TOKEN.equals(grant.getType()) && !isPublicClient() ?
+                null : tokenClaimsSet.getDpopProofJwkThumbprint();
         final String proofThumbprint = dpopProofThumbprintLookupStrategy.apply(profileRequestContext);
         if (claimsSetThumbprint == null) {
             oidcResponseContext.setDpopProofJwkThumbprint(proofThumbprint);
         } else if (proofThumbprint == null) {
+            if (GrantType.REFRESH_TOKEN.equals(grant.getType()) && isPublicClient()) {
+                log.warn("{} Missing mandatory DPoP proof for public client with refresh token: {}", getLogPrefix(),
+                        clientId.getValue());
+                ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+                return;
+            }
             oidcResponseContext.setDpopProofJwkThumbprint(claimsSetThumbprint);
         } else if (!claimsSetThumbprint.equals(proofThumbprint)) {
             log.warn("{} Invalid DPoP Proof thumbprint issued to client {}, invalid for {}", getLogPrefix(),
@@ -413,6 +423,25 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
     }
 // Checkstyle: CyclomaticComplexity|MethodLength|ReturnCount ON
 
+    /**
+     * Checks if the client is a public client. If not metadata exists for the client, it's assumed to be confidential.
+     * The client is considered as confidential if its metadata doesn't specify "none" as the token endpoint
+     * authentication method.
+     * 
+     * @return true if the client is public, false otherwise.
+     */
+    protected boolean isPublicClient() {
+        final OIDCMetadataContext metadataContext = getMetadataContext();
+        if (metadataContext != null) {
+            final OIDCClientInformation clientInformation = metadataContext.getClientInformation();
+            if (clientInformation != null) {
+                return ClientAuthenticationMethod.NONE.equals(
+                        clientInformation.getOIDCMetadata().getTokenEndpointAuthMethod());
+            }
+        }
+        return false;
+    }
+
     /**
      * Attempt to deseriaalize a (serialized) refresh token value via configured deserializers.
      * 
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
index 665207a7..9b6a49ba 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/TokenFlowTest.java
@@ -747,6 +747,127 @@ public class TokenFlowTest extends AbstractOidcClientAuthenticationFlowTest {
         Assert.assertNotNull(response.getOIDCTokens().getIDToken());
     }
 
+    @Test
+    public void testDPoPValidGrantThumbprintIncludedMatchingProof_publicClient() throws Exception {
+        final String clientId = clientIdPkcePlainPublic;
+        final AuthorizeCodeClaimsSet.Builder builder = new AuthorizeCodeClaimsSet.Builder();
+        final SignedJWT dpopProof = buildDPoPProof("POST", "http://localhost/idp/profile/oidc/token",
+                createValidDPoPNonce());
+        builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
+            .setClientID(new ClientID(clientId))
+            .setIssuer("https://op.example.org")
+            .setPrincipal("jdoe")
+            .setSubject("mock")
+            .setIssuedAt(Instant.now())
+            .setExpiresAt(Instant.now().plusSeconds(100))
+            .setAuthenticationTime(Instant.now())
+            .setRedirectURI(new URI(redirectUri))
+            .setScope(scope)
+            .setCodeChallenge(plainVerifier())
+            .setDpopProofJwkThumbprint(dpopProof.getHeader().getJWK().computeThumbprint().toString());
+        final String authorizationCode = builder.build().serialize(getDataSealer());
+        request.addHeader("DPoP", dpopProof.serialize());
+
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "authorization_code",
+                authorizationCode, clientId, null, null,
+                codeVerifier), false, ClientAuthenticationMethod.NONE);
+        storeConsent(storageService, "jdoe", clientId, "mail");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        Assert.assertNotNull(response.getTokens().getAccessToken());
+        Assert.assertEquals(response.getTokens().getAccessToken().getType(), AccessTokenType.DPOP);
+        Assert.assertNotNull(response.getTokens().getRefreshToken());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+    }
+
+    @Test
+    public void testDPoPValidGrantThumbprintIncludedMatchingProof_publicClientRefresh() throws Exception {
+        final String clientId = clientIdPkcePlainPublic;
+        final RefreshTokenClaimsSet.Builder builder = new RefreshTokenClaimsSet.Builder();
+        final SignedJWT dpopProof = buildDPoPProof("POST", "http://localhost/idp/profile/oidc/token",
+                createValidDPoPNonce());
+        builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
+            .setClientID(new ClientID(clientId))
+            .setIssuer("https://op.example.org")
+            .setPrincipal("jdoe")
+            .setSubject("mock")
+            .setIssuedAt(Instant.now())
+            .setExpiresAt(Instant.now().plusSeconds(100))
+            .setAuthenticationTime(Instant.now())
+            .setRedirectURI(new URI(redirectUri))
+            .setScope(scope)
+            .setDpopProofJwkThumbprint(dpopProof.getHeader().getJWK().computeThumbprint().toString());
+        final String refreshToken = builder.build().serialize(getDataSealer());
+        request.addHeader("DPoP", dpopProof.serialize());
+
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                refreshToken, clientId), false, ClientAuthenticationMethod.NONE);
+        storeConsent(storageService, "jdoe", clientId, "mail");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        Assert.assertNotNull(response.getTokens().getAccessToken());
+        Assert.assertEquals(response.getTokens().getAccessToken().getType(), AccessTokenType.DPOP);
+        Assert.assertNotNull(response.getTokens().getRefreshToken());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+    }
+
+    @Test
+    public void testDPoPValidGrantThumbprintIncludedNonMatchingProof_publicClientRefresh() throws Exception {
+        final String clientId = clientIdPkcePlainPublic;
+        final RefreshTokenClaimsSet.Builder builder = new RefreshTokenClaimsSet.Builder();
+        final SignedJWT dpopProof = buildDPoPProof("POST", "http://localhost/idp/profile/oidc/token",
+                createValidDPoPNonce());
+        builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
+            .setClientID(new ClientID(clientId))
+            .setIssuer("https://op.example.org")
+            .setPrincipal("jdoe")
+            .setSubject("mock")
+            .setIssuedAt(Instant.now())
+            .setExpiresAt(Instant.now().plusSeconds(100))
+            .setAuthenticationTime(Instant.now())
+            .setRedirectURI(new URI(redirectUri))
+            .setScope(scope)
+            .setDpopProofJwkThumbprint("mockJkt");
+        final String refreshToken = builder.build().serialize(getDataSealer());
+        request.addHeader("DPoP", dpopProof.serialize());
+
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                refreshToken, clientId), false, ClientAuthenticationMethod.NONE);
+        storeConsent(storageService, "jdoe", clientId, "mail");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, OAuth2Error.INVALID_DPOP_PROOF_CODE);
+    }
+
+    @Test
+    public void testDPoPValidGrantThumbprintIncludedNonMatchingProof_confidentialClientRefresh() throws Exception {
+        final RefreshTokenClaimsSet.Builder builder = new RefreshTokenClaimsSet.Builder();
+        final SignedJWT dpopProof = buildDPoPProof("POST", "http://localhost/idp/profile/oidc/token",
+                createValidDPoPNonce());
+        builder.setJWTID(new SecureRandomIdentifierGenerationStrategy())
+            .setClientID(new ClientID(clientId))
+            .setIssuer("https://op.example.org")
+            .setPrincipal("jdoe")
+            .setSubject("mock")
+            .setIssuedAt(Instant.now())
+            .setExpiresAt(Instant.now().plusSeconds(100))
+            .setAuthenticationTime(Instant.now())
+            .setRedirectURI(new URI(redirectUri))
+            .setScope(scope)
+            .setDpopProofJwkThumbprint("mockJkt");
+        final String refreshToken = builder.build().serialize(getDataSealer());
+        request.addHeader("DPoP", dpopProof.serialize());
+
+        initializeGrantAndRequest(clientId, createRequestParameters(redirectUri, "refresh_token",
+                refreshToken, clientId));
+        storeConsent(storageService, "jdoe", clientId, "mail");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final OIDCTokenResponse response = parseSuccessResponse(result, OIDCTokenResponse.class);
+        Assert.assertNotNull(response.getTokens().getAccessToken());
+        Assert.assertEquals(response.getTokens().getAccessToken().getType(), AccessTokenType.DPOP);
+        Assert.assertNotNull(response.getTokens().getRefreshToken());
+        Assert.assertNotNull(response.getOIDCTokens().getIDToken());
+    }
+
     @Test
     public void testDPoPValidGrantThumbprintNotIncluded() throws Exception {
         final AuthorizeCodeClaimsSet.Builder builder = new AuthorizeCodeClaimsSet.Builder();

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list