You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ro...@apache.org on 2024/12/10 14:18:41 UTC

(sling-org-apache-sling-auth-oauth-client) branch master updated: feat: introduce a high-level API for retrieving access tokens (#5)

This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-auth-oauth-client.git


The following commit(s) were added to refs/heads/master by this push:
     new 7808fdb  feat: introduce a high-level API for retrieving access tokens (#5)
7808fdb is described below

commit 7808fdbeac0411aab4262f0dbbe61cfd5784c9e6
Author: Robert Munteanu <ro...@apache.org>
AuthorDate: Tue Dec 10 15:18:37 2024 +0100

    feat: introduce a high-level API for retrieving access tokens (#5)
---
 README.md                                          |  98 ++++++++++++--
 .../sling/auth/oauth_client/OAuthTokenAccess.java  |  69 ++++++++++
 .../auth/oauth_client/OAuthTokenResponse.java      |  83 ++++++++++++
 .../sling/auth/oauth_client/TokenAccessImpl.java   | 100 ++++++++++++++
 .../support/OAuthEnabledSlingServlet.java          |  46 ++-----
 .../auth/oauth_client/TokenAccessImplTest.java     | 149 +++++++++++++++++++++
 6 files changed, 498 insertions(+), 47 deletions(-)

diff --git a/README.md b/README.md
index 8de530e..997eb5b 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,48 @@ the authentication code flow based on OIDC and OAuth 2.0 .
 
 ## Usage
 
-### High-level APIs
+### Models and other Java APIs
+
+The `OAuthTokenAccess` OSGi service exposes methods to retrieve and clear access tokens. These methods encapsulate
+persistence concerns and handle refresh tokens transparently, if present.
+
+```java
+@Model(adaptables = SlingHttpServletRequest.class)
+public class MyModel {
+    
+    @SlingObject private SlingHttpServletRequest request;
+    
+    @OSGiService(filter = "(name=foo)") private ClientConnection connection;
+    
+    @OSGiService private OAuthTokenAccess tokenAccess;
+
+    private OAuthTokenResponse tokenResponse;
+    
+    @PostConstruct
+    public void initToken() {
+        tokenResponse = tokenAccess.getAccessToken(connection, request, request.getRequestURI());
+    }
+    
+    public MyView getResponse() {
+        if ( tokenResponse.hasValidToken() ) {
+            return doQuery(tokenResponse.getTokenValue());
+        }
+        
+        return null;
+    }
+    
+    public String getRedirectLink() {
+        if ( !tokenResponse.hasValidToken() ) {
+            return tokenResponse.getRedirectUri().toString();
+        }
+        
+        return null;
+    }
+}
+
+```
+
+### Servlets
 
 The bundle exposes an abstract `OAuthEnabledSlingServlet` that contains the boilerplate code needed
 to obtain a valid OAuth 2 access token.
@@ -36,10 +77,9 @@ public class MySlingServlet extends OAuthEnabledSlingServlet {
    
     @Activate
     public MySlingServlet(@Reference OidcConnection connection, 
-        @Reference OAuthTokenStore tokenStore,
-        @Reference OAuthTokenRefresher oidcClient,
+        @Reference OAuthTokenAccess tokenAccess,
         @Reference MyRemoteService svc) {
-        super(connection, tokenStore, oidcClient);
+        super(connection, tokenAccess);
         this.svc = svc;
     }
 
@@ -52,9 +92,6 @@ public class MySlingServlet extends OAuthEnabledSlingServlet {
 }
 ```
 
-### Low-level APIs
-
-TODO
 
 ### Clearing access tokens
 
@@ -65,24 +102,57 @@ invalidated out of band.
 The client will need to determine if the access token is invalid as this is a provider-specific
 check.
 
-To remove invalid access tokens there is a low-level API
+#### When the request and response are available
+
+This method is generally recommended as it permits the generation of a redirect URI that will kick
+off a new OAuth authorisation flow.
+
 
 ```java
-public class MyComponent {}
-    @Reference private OAuthTokenStore tokenStore;
+@Model(adaptables = SlingHttpServletRequest.class)
+public class MySlingModel {
+    @OSGiService private OAuthTokenAccess tokenAccess;
+    @SlingObject SlingHttpServletRequest request;
+    @OSGiService(filter = "(name=foo)") private ClientConnection connection;
+    
+    public String getLink() {
+        // code elided
+        if ( accessTokenIsInvalid() ) {
+            OAuthTokenResponse response = tokenAccess.clearAccessToken(connection, request, request.getRequestURI());
+            return response.getRedirectUri().toString();
+        }
+    }
+}
+```
+
+
+#### When the request and response are not available
+
+
+This approach should be used when invalidating access tokens without user interaction, as it does not
+provide a mechanism to generate a redirect URL for restarting the OAuth authorisation flow and obtaining
+a new access token.
+
+```java
+@Component
+public class MyComponent {
+    @Reference private OAuthTokenAccess tokenAccess;
     
     public void execute(@Reference OidcConnection connection, ResourceResolver resolver) {
         // code elided
         if ( accessTokenIsInvalid() ) {
-            tokenStore.clearAccessToken(connection, resolver);
-            // redirect to provider or present a message to the user
+            tokenAccess.clearAccessToken(connection, resolver);
         }
     }
 }
 ```
 
-For classes that extend from the `OAuthEnabledSlingServlet` the following method override can be
-applied
+
+
+#### When extending OAuthEnabledSlingServlet
+
+For classes that extend from the `OAuthEnabledSlingServlet` the `isInvalidAccessTokenException` method can be
+overriden. If this method returns true, the access token is cleared and a new OAuth flow is started.
 
 ```java
 @Component(service = { Servlet.class })
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenAccess.java b/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenAccess.java
new file mode 100644
index 0000000..07815b5
--- /dev/null
+++ b/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenAccess.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.auth.oauth_client;
+
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Entry point for accessing and clearing OAuth access tokens
+ * 
+ * <p>The tokens are stored distinctly for each client connection and user. The client connection is identified by 
+ * {@link ClientConnection#name() name} and the user is identified by the {@link ResourceResolver#getUserID() user id}. </p>
+ * 
+ * <p>The storage strategy may vary and is controlled by the currently active implementation of the {@link OAuthTokenStore}.</p>
+ */
+@NotNull
+public interface OAuthTokenAccess {
+
+    /**
+     * Retrieves an existing access, valid, access token from storage.
+     * 
+     * <p>Refreshes expired access tokens if a refresh token is available but does not attempt to retrieve new access tokens.</p>
+     * 
+     * @param connection the client connection to retrieve token for
+     * @param request the request used to determine the current user for which to retrieve the token and to build the redirect URL
+     * @param redirectPath the path to redirect to after completing the OAuth flow
+     * @return the token response
+     */
+    OAuthTokenResponse getAccessToken(ClientConnection connection, SlingHttpServletRequest request, String redirectPath);
+
+    /**
+     * Clears the access token for the given connection and user, as identified by the request.
+     * 
+     * <p>Returns a response that does not have a valid token and contains a URI to redirect the user to.</p>
+     * 
+     * @param connection the client connection to clear the token for
+     * @param request the request used to determine the current user for which to retrieve the token and to build the redirect URL
+     * @param redirectPath the path to redirect to after completing the OAuth flow 
+     * @return the token response
+     */
+    OAuthTokenResponse clearAccessToken(ClientConnection connection, SlingHttpServletRequest request, String redirectPath);
+
+    /**
+     * Clears the access token for the given connection and user, as identified by the resource resolver
+     * 
+     * <p>For scenarios where a redirect URI should be generated after clearing the access token {@link #clearAccessToken(ClientConnection, SlingHttpServletRequest, String)}
+     * should be used instead.</p>
+     * 
+     * @param connection the client connection to clear the token for
+     * @param resolver used to determine the current user for which to retrieve the token
+     */
+    void clearAccessToken(ClientConnection connection, ResourceResolver resolver);
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenResponse.java b/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenResponse.java
new file mode 100644
index 0000000..8d3553a
--- /dev/null
+++ b/src/main/java/org/apache/sling/auth/oauth_client/OAuthTokenResponse.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.auth.oauth_client;
+
+import java.net.URI;
+import java.util.Optional;
+
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Encapsulates the response to a token request.
+ * 
+ * <p>This class has two top-level states:</p>
+ * <ol>
+ *   <li>has a valid access token: {@link #hasValidToken()} returns {@code true}, and {@link #getTokenValue()} returns the token value.</li>
+ *   <li>does not have a valid access token: {@link #hasValidToken()} returns {@code false}, and {@link #getRedirectUri()} returns the URI to redirect the user to.</li>
+ * </ol>
+ * 
+ * <p>Methods generally throw {@link IllegalStateException} if they are called in an unexpected state and do not return null values.</p>
+ */
+@NotNull
+public class OAuthTokenResponse {
+    
+    private final Optional<String> token;
+    private final ClientConnection connection;
+    private final SlingHttpServletRequest request;
+    private String redirectPath;
+    
+    public OAuthTokenResponse(Optional<String> token, ClientConnection connection, SlingHttpServletRequest request, String redirectPath) {
+        this.token = token;
+        this.connection = connection;
+        this.request = request;
+        this.redirectPath = redirectPath;
+    }
+
+    /**
+     * Returns true if a valid access token is present and false otherwise
+     * 
+     * @return true if a valid access token is present
+     */
+    public boolean hasValidToken() {
+        return token.isPresent();
+    }
+    
+    
+    /**
+     * Returns the a valid access token value and throws an {@link IllegalStateException} otherwise
+     * 
+     * @return a valid access token value
+     * @throws IllegalStateException if no access token is present
+     */
+    public String getTokenValue() {
+        return token.orElseThrow(() -> new IllegalStateException("No access token present."));
+    }
+    
+    /**
+     * Returns the URI to redirect the user to in order to start the OAuth flow
+     * 
+     * @return the URI to redirect the user to
+     * @throws IllegalStateException if an access token is present
+     */
+    public URI getRedirectUri() {
+        if ( token.isPresent() )
+            throw new IllegalStateException("Access token is present, will not generate a new redirect URI.");
+        
+        return OAuthUris.getOidcEntryPointUri(connection, request, redirectPath);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/TokenAccessImpl.java b/src/main/java/org/apache/sling/auth/oauth_client/TokenAccessImpl.java
new file mode 100644
index 0000000..bad019f
--- /dev/null
+++ b/src/main/java/org/apache/sling/auth/oauth_client/TokenAccessImpl.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.auth.oauth_client;
+
+import java.util.Optional;
+
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component
+public class TokenAccessImpl implements OAuthTokenAccess {
+    
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+    
+    private OAuthTokenStore tokenStore;
+    private OAuthTokenRefresher tokenRefresher;
+    
+    @Activate
+    public TokenAccessImpl(@Reference OAuthTokenStore tokenStore, @Reference OAuthTokenRefresher tokenRefresher) {
+        this.tokenStore = tokenStore;
+        this.tokenRefresher = tokenRefresher;
+    }
+    
+    @Override
+    public OAuthTokenResponse getAccessToken(ClientConnection connection, SlingHttpServletRequest request, String redirectPath) {
+        
+        ResourceResolver resolver = request.getResourceResolver();
+        
+        OAuthToken token = tokenStore.getAccessToken(connection, resolver);
+        
+        if ( logger.isDebugEnabled() )
+            logger.debug("Accessing token for connection {} and user {}", connection.name(), request.getUserPrincipal());
+        
+        // valid access token present -> return token
+        if (token.getState() == TokenState.VALID) {
+            if (logger.isDebugEnabled())
+                logger.debug("Returning valid access token for connection {} and user {}", connection.name(), request.getUserPrincipal());
+            
+            return new OAuthTokenResponse(Optional.of(token.getValue()), connection, request, redirectPath);
+        }
+        
+        // expired token but refresh token present -> refresh and return
+        if (token.getState() == TokenState.EXPIRED) {
+            OAuthToken refreshToken = tokenStore.getRefreshToken(connection, resolver);
+            if (refreshToken.getState() == TokenState.VALID) {
+                if (logger.isDebugEnabled())
+                    logger.debug("Refreshing expired access token for connection {} and user {}", connection.name(), request.getUserPrincipal());
+
+                OAuthTokens newTokens = tokenRefresher.refreshTokens(connection, refreshToken.getValue());
+                tokenStore.persistTokens(connection, resolver, newTokens);
+                return new OAuthTokenResponse(Optional.of(newTokens.accessToken()), connection, request, redirectPath);
+            }
+        }
+
+        // all other scenarios -> redirect
+        if ( logger.isDebugEnabled() )
+            logger.debug("No valid access token found for connection {} and user {}", connection.name(), request.getUserPrincipal());
+        
+        return new OAuthTokenResponse(Optional.empty(), connection, request, redirectPath);
+    }
+    
+    @Override
+    public OAuthTokenResponse clearAccessToken(ClientConnection connection, SlingHttpServletRequest request, String redirectPath) {
+        
+        if ( logger.isDebugEnabled() )
+            logger.debug("Clearing access token for connection {} and user {}", connection.name(), request.getUserPrincipal());
+
+        tokenStore.clearAccessToken(connection, request.getResourceResolver());
+        
+        return new OAuthTokenResponse(Optional.empty(), connection, request, redirectPath);
+    }
+    
+    @Override
+    public void clearAccessToken(ClientConnection connection, ResourceResolver resolver) {
+
+        if ( logger.isDebugEnabled() )
+            logger.debug("Clearing access token for connection {} and user {}", connection.name(), resolver.getUserID());
+
+        tokenStore.clearAccessToken(connection, resolver);
+    }
+}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/support/OAuthEnabledSlingServlet.java b/src/main/java/org/apache/sling/auth/oauth_client/support/OAuthEnabledSlingServlet.java
index d92f50d..2b61046 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/support/OAuthEnabledSlingServlet.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/support/OAuthEnabledSlingServlet.java
@@ -27,10 +27,8 @@ import org.apache.sling.api.SlingHttpServletResponse;
 import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
 import org.apache.sling.auth.oauth_client.ClientConnection;
 import org.apache.sling.auth.oauth_client.OAuthToken;
-import org.apache.sling.auth.oauth_client.OAuthTokenRefresher;
-import org.apache.sling.auth.oauth_client.OAuthTokenStore;
-import org.apache.sling.auth.oauth_client.OAuthTokens;
-import org.apache.sling.auth.oauth_client.OAuthUris;
+import org.apache.sling.auth.oauth_client.OAuthTokenAccess;
+import org.apache.sling.auth.oauth_client.OAuthTokenResponse;
 import org.apache.sling.auth.oauth_client.TokenState;
 import org.jetbrains.annotations.NotNull;
 import org.slf4j.Logger;
@@ -43,14 +41,11 @@ public abstract class OAuthEnabledSlingServlet extends SlingSafeMethodsServlet {
 
     private final ClientConnection connection;
 
-    private final OAuthTokenStore tokenStore;
-
-    private final OAuthTokenRefresher oidcClient;
+    private final OAuthTokenAccess tokenAccess;
 	
-    protected OAuthEnabledSlingServlet(ClientConnection connection, OAuthTokenStore tokenStore, OAuthTokenRefresher oidcClient) {
+    protected OAuthEnabledSlingServlet(ClientConnection connection, OAuthTokenAccess tokenAccess) {
         this.connection = Objects.requireNonNull(connection, "connection may not null");
-        this.tokenStore = Objects.requireNonNull(tokenStore, "tokenStore may not null");
-        this.oidcClient = Objects.requireNonNull(oidcClient, "oidcClient may not null");
+        this.tokenAccess = Objects.requireNonNull(tokenAccess, "tokenAccess may not null");
     }
 
 	@Override
@@ -67,26 +62,11 @@ public abstract class OAuthEnabledSlingServlet extends SlingSafeMethodsServlet {
 	    if ( logger.isDebugEnabled() )
 	        logger.debug("Configured with connection (name={}) and redirectPath={}", connection.name(), redirectPath);
 	    
-	    OAuthToken tokenResponse = tokenStore.getAccessToken(connection, request.getResourceResolver());
-	    
-		switch ( tokenResponse.getState() ) {
-	      case VALID:
-            doGetWithPossiblyInvalidToken(request, response, tokenResponse, redirectPath);
-	        break;
-	      case MISSING:
-	        response.sendRedirect(OAuthUris.getOidcEntryPointUri(connection, request, redirectPath).toString());
-	        break;
-	      case EXPIRED:
-	        OAuthToken refreshToken = tokenStore.getRefreshToken(connection, request.getResourceResolver());
-	        if ( refreshToken.getState() != TokenState.VALID ) {
-	          response.sendRedirect(OAuthUris.getOidcEntryPointUri(connection, request, redirectPath).toString());
-	          return;
-	        }
-	        
-	        OAuthTokens oidcTokens = oidcClient.refreshTokens(connection, refreshToken.getValue());
-	        tokenStore.persistTokens(connection, request.getResourceResolver(), oidcTokens);
-	        doGetWithPossiblyInvalidToken(request, response, new OAuthToken(TokenState.VALID, oidcTokens.accessToken()), redirectPath);
-	        break;
+	    OAuthTokenResponse tokenResponse = tokenAccess.getAccessToken(connection, request, redirectPath);
+	    if (tokenResponse.hasValidToken() ) {
+	        doGetWithPossiblyInvalidToken(request, response, new OAuthToken(TokenState.VALID, tokenResponse.getTokenValue()), redirectPath);
+	    } else {
+	        response.sendRedirect(tokenResponse.getRedirectUri().toString());
 	    }
 	}
 	
@@ -95,9 +75,9 @@ public abstract class OAuthEnabledSlingServlet extends SlingSafeMethodsServlet {
             doGetWithToken(request, response, token);
         } catch (ServletException | IOException e) {
             if (isInvalidAccessTokenException(e)) {
-                logger.warn("Invalid access token, clearing and attempting to retrieve a fresh one", e);
-                tokenStore.clearAccessToken(connection, request.getResourceResolver());
-                response.sendRedirect(OAuthUris.getOidcEntryPointUri(connection, request, redirectPath).toString());
+                logger.warn("Invalid access token, clearing restarting OAuth flow", e);
+                OAuthTokenResponse tokenResponse = tokenAccess.clearAccessToken(connection, request, getRedirectPath(request));
+                response.sendRedirect(tokenResponse.getRedirectUri().toString());
             } else {
                 throw e;
             }
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/TokenAccessImplTest.java b/src/test/java/org/apache/sling/auth/oauth_client/TokenAccessImplTest.java
new file mode 100644
index 0000000..0f17245
--- /dev/null
+++ b/src/test/java/org/apache/sling/auth/oauth_client/TokenAccessImplTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.auth.oauth_client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.sling.auth.oauth_client.impl.MockOidcConnection;
+import org.apache.sling.testing.mock.sling.junit5.SlingContext;
+import org.apache.sling.testing.mock.sling.junit5.SlingContextExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(SlingContextExtension.class)
+class TokenAccessImplTest {
+    
+    private SlingContext slingContext = new SlingContext();
+
+    @Test
+    void missingAccessToken() {
+        
+        OAuthTokenStore tokenStore = new InMemoryOAuthTokenStore();
+        
+        TokenAccessImpl tokenAccess = new TokenAccessImpl(tokenStore, null);
+        
+        OAuthTokenResponse tokenResponse = tokenAccess.getAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        
+        assertThat(tokenResponse)
+            .as("tokenResponse")
+            .isNotNull()
+            .satisfies( tr -> {
+                assertThat(tr.hasValidToken()).as("hasValidToken").isFalse();
+                assertThrows(IllegalStateException.class, tr::getTokenValue, "getTokenValue");
+                assertThat(tr.getRedirectUri()).as("redirectUri")
+                    .isNotNull()
+                    .asString()
+                    .isNotBlank();
+            });
+    }
+    
+    @Test
+    void presentAccessToken() {
+        OAuthTokenStore tokenStore = new InMemoryOAuthTokenStore();
+
+        TokenAccessImpl tokenAccess = new TokenAccessImpl(tokenStore, null);
+        
+        tokenStore.persistTokens(MockOidcConnection.DEFAULT_CONNECTION, slingContext.resourceResolver(), new OAuthTokens("access", 0, null));
+        
+        OAuthTokenResponse tokenResponse = tokenAccess.getAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        
+        assertThat(tokenResponse)
+            .as("tokenResponse")
+            .isNotNull()
+            .satisfies( tr -> {
+                assertThat(tr.hasValidToken()).as("hasValidToken").isTrue();
+                assertThat(tr.getTokenValue()).as("tokenValue").isEqualTo("access");
+                assertThrows(IllegalStateException.class, tr::getRedirectUri, "getRedirectUri");
+            });
+    }
+    
+    @Test
+    void refreshTokenUsed() {
+        
+        OAuthTokens expiredTokens = new OAuthTokens("access", -1, "refresh");
+        OAuthTokens refreshedTokens = new OAuthTokens("access2", 0, null);
+        
+        OAuthTokenStore tokenStore = new InMemoryOAuthTokenStore();
+        
+        OAuthTokenRefresher tokenRefresher = new OAuthTokenRefresher() {
+            @Override
+            public OAuthTokens refreshTokens(ClientConnection connection, String refreshToken) {
+                if (!refreshToken.equals(expiredTokens.refreshToken()))
+                    throw new IllegalArgumentException("Invalid refresh token");
+                
+                return refreshedTokens;
+            }
+        };
+
+        TokenAccessImpl tokenAccess = new TokenAccessImpl(tokenStore, tokenRefresher);
+
+        tokenStore.persistTokens(MockOidcConnection.DEFAULT_CONNECTION, slingContext.resourceResolver(), expiredTokens);
+        
+        OAuthTokenResponse tokenResponse = tokenAccess.getAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        
+        assertThat(tokenResponse)
+            .as("tokenResponse")
+            .isNotNull()
+            .satisfies( tr -> {
+                assertThat(tr.hasValidToken()).as("hasValidToken").isTrue();
+                assertThat(tr.getTokenValue()).as("tokenValue").isEqualTo(refreshedTokens.accessToken());
+                assertThrows(IllegalStateException.class, tr::getRedirectUri, "getRedirectUri");
+            });
+    }
+    
+    @Test
+    void clearAccessTokenWithResponse() {
+        
+        OAuthTokenStore tokenStore = new InMemoryOAuthTokenStore();
+
+        TokenAccessImpl tokenAccess = new TokenAccessImpl(tokenStore, null);
+        
+        tokenStore.persistTokens(MockOidcConnection.DEFAULT_CONNECTION, slingContext.resourceResolver(), new OAuthTokens("access", 0, null));
+
+        OAuthTokenResponse okResponse = tokenAccess.getAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        assertThat(okResponse.hasValidToken()).isTrue();
+        
+        OAuthTokenResponse clearResponse = tokenAccess.clearAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        
+        assertThat(clearResponse)
+            .as("tokenResponse after clear")
+            .isNotNull()
+            .extracting(OAuthTokenResponse::hasValidToken)
+            .isEqualTo(false);
+    }
+    
+    @Test
+    void clearAccessTokenWithoutResponse() {
+        
+        InMemoryOAuthTokenStore tokenStore = new InMemoryOAuthTokenStore();
+
+        TokenAccessImpl tokenAccess = new TokenAccessImpl(tokenStore, null);
+        
+        tokenStore.persistTokens(MockOidcConnection.DEFAULT_CONNECTION, slingContext.resourceResolver(), new OAuthTokens("access", 0, null));
+
+        OAuthTokenResponse okResponse = tokenAccess.getAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.request(), "/");
+        assertThat(okResponse.hasValidToken()).isTrue();
+        
+        tokenAccess.clearAccessToken(MockOidcConnection.DEFAULT_CONNECTION, slingContext.resourceResolver());
+        
+        assertThat(tokenStore.allTokens())
+            .as("all persisted tokens")
+            .isEmpty();
+    }
+
+}