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

Refresh Token Inaccessible #497

Open
MIJohnson opened this issue May 23, 2023 · 6 comments
Open

Refresh Token Inaccessible #497

MIJohnson opened this issue May 23, 2023 · 6 comments

Comments

@MIJohnson
Copy link

MIJohnson commented May 23, 2023

I'm using this plugin to authenticate using JWT and Oauth2 (via Keycloak)

There is a accessToken api call provided in the RestOauthController where a valid refreshToken can be provided to request/generate a fresh accessToken. But how is one supposed to retrieve/surface the refreshToken initially generated for the first AccessToken? I cant find any documentation on this, and looking at the source code it doesn't seem possible?

Pointers greatly appreciated

@jdaugherty
Copy link
Contributor

The refreshToken is only provided by creating a token. If the user wishes to create another, they need to login again to create one. https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/ covers the background for the token and since it's only used to renew a previously issued token, retrieving it wouldn't make sense.

@MIJohnson
Copy link
Author

MIJohnson commented Sep 28, 2024

Thanks for considering the question, but please let me describe this a different way.

I do not believe you can use refreshTokens out the box with this plugin. IIRC because the authenticating client has no way to get the first refreshToken.

I have got it working in my software with the plugin but I have had to change a chunk of code (modifying the existing classes in the plugin) to make it work.

This plugin is great for a lot of auth things, but its not quite yet fit for purpose for Oauth2 in my opinion.

@jdaugherty
Copy link
Contributor

Issuing a POST request to '/oauth/access_token' with: the refresh_token from the login and a grant_type of refresh_token will reissue a new JWT token. An example request would be:

 POST /oauth/access_token HTTP/1.1

 Host: www.example.com

 Content-Type: application/x-www-form-urlencoded

 grant_type=refresh_token&refresh_token=eyJhbGciOiJIUzI1NiJ9...

Can you elaborate why this does not work for you?

@MIJohnson
Copy link
Author

MIJohnson commented Sep 28, 2024

From what I remember, when you get the access token in the first place it does not contain the refresh token which you need to issue the above request

How is the authenticating client (ie angular web app) supposed to get hold of the refresh token?

The grails oauth service only exposes the accesstoken string - see grails.plugin.springsecurity.rest.RestOauthService.groovy line 107

@MIJohnson
Copy link
Author

This is probably not the best code as it was written under tight time constraints, but its what I ended up with to get refresh tokens working (I ended up supplying the refresh token as a chunked cookie because of its size)

`
import org.springframework.http.HttpStatus
import static org.springframework.http.HttpStatus.NO_CONTENT
import static org.springframework.http.HttpStatus.FORBIDDEN
import static org.springframework.http.HttpStatus.BAD_REQUEST

import org.springframework.web.util.UriComponentsBuilder
import org.springframework.http.ResponseEntity
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.client.RestTemplate

import groovy.util.logging.Slf4j

import org.pac4j.oidc.profile.OidcProfile

import org.pac4j.core.context.WebContext
import org.pac4j.core.context.J2EContext

import javax.servlet.http.Cookie
import org.springframework.http.ResponseCookie

import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.rest.oauth.OauthUser
import grails.plugin.springsecurity.rest.RestOauthController
import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException
import grails.plugin.springsecurity.rest.token.AccessToken
import org.springframework.security.core.userdetails.User

import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.context.SecurityContext

import java.nio.charset.StandardCharsets
import java.net.URLEncoder
import java.time.Duration

import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

import com.k_int.ciim.mgmt.auth.HttpServletRequestDebug

//for auth override
import org.pac4j.core.client.IndirectClient
import org.pac4j.core.context.J2EContext
import org.pac4j.core.context.WebContext
import org.pac4j.core.redirect.RedirectAction
import org.apache.commons.codec.binary.Base64

@slf4j
class CustomRestOauthController extends RestOauthController {

SpringSecurityService springSecurityService

/**
 * Starts the OAuth authentication flow, redirecting to the provider's Login URL. An optional callback parameter
 * allows the frontend application to define the frontend callback URL on demand.
 */
@Override
def authenticate(String provider, String callback) {
    IndirectClient client = restOauthService.getClient(provider)
    WebContext context = new J2EContext(request, response)

    HttpServletRequestDebug.printRequest(request)

    if (callback) {
        try {
            if (Base64.isBase64(callback.getBytes())){
                callback = new String(callback.decodeBase64(), StandardCharsets.UTF_8)
            }
            log.debug "Trying to store in the HTTP session a user specified callback URL: ${callback}"
            session[CALLBACK_ATTR] = new URL(callback).toString()
        } catch (MalformedURLException mue) {
            log.warn "The URL is malformed, is it base64 encoded? Not storing it."
        }
    }

    RedirectAction redirectAction = client.getRedirectAction(context)
    log.error "Redirecting to ${redirectAction.location}"

    redirect url: redirectAction.location
}

// No logout in the default implementation so roll our own
def logout() {
    RestTemplate restTemplate = new RestTemplate();

    OidcProfile userProfile = (OidcProfile) ((OauthUser) springSecurityService.getPrincipal()).userProfile

    //this allows us to logout without having to send the user to keycloak logout page
    String endSessionEndpoint = userProfile.getIssuer() + "/protocol/openid-connect/logout";
    UriComponentsBuilder builder = UriComponentsBuilder
      .fromUriString(endSessionEndpoint)
      .queryParam("id_token_hint", userProfile.getIdTokenString());

    ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);

    if (logoutResponse.getStatusCode().is2xxSuccessful()) {
        log.info("Successfully logged out from Keycloak");
    } 
    else {
        log.error("Could not propagate logout to Keycloak");
    }

    //clear refresh token Cookies 
    eraseCookies(request, response)

    // Finally logout local session? not sure this is required
    request.logout()

    render status: NO_CONTENT
}

/* Automatically try to refresh the access token, if access token expire within a grace period */
def refreshToken()
{
    String token = request.JSON['access_token']

    if (token) {
        try {
            //stitch the refresh token together from cookies

            String refreshTokenString = "";
            Cookie[] cookies = request.getCookies()
            if (cookies != null) {
                for (Cookie cookie : cookies) {

                    String cookieName = cookie.getName();
                    log.debug("found ${cookieName}")

                    if(cookieName.indexOf("refresh_token") > -1) {
                        refreshTokenString += cookie.getValue();
                    }
                }
            }

            //validate token vs refresh

            if (refreshTokenString) {
                try {
                    def user = tokenStorageService.loadUserByToken(refreshTokenString)
                    User principal = user ? user as User : null
                    log.debug "Principal found for refresh token: ${principal}"

                    AccessToken accessToken = tokenGenerator.generateAccessToken(principal, false)
                    accessToken.refreshToken = refreshTokenString

                    authenticationEventPublisher.publishTokenCreation(accessToken)

                    response.addHeader 'Cache-Control', 'no-store'
                    response.addHeader 'Pragma', 'no-cache'
                    render contentType: 'application/json', encoding: 'UTF-8',  text:  accessTokenJsonRenderer.generateJson(accessToken)
                } catch (exception) {
                    log.error("Error refreshing token:", exception)
                    render status: HttpStatus.FORBIDDEN
                }
            } 
            else {
                log.debug "Refresh token is missing. Replying with bad request"
                render status: HttpStatus.BAD_REQUEST, text: "Refresh token is required"
            }
        } catch (exception) {
            log.error("Error refreshing token:", exception)
            render status: HttpStatus.FORBIDDEN
        }
    } else {
        log.debug "Access token is missing. Replying with bad request"
        render status: HttpStatus.BAD_REQUEST, text: "Access token is required"
    }
}

 /**
 * Handles the OAuth provider callback. It uses {@link RestOauthService} to generate and store a token for that user,
 * and finally redirects to the configured frontend callback URL, where the token is in the URL. That way, the
 * frontend application can store the REST API token locally for subsequent API calls.
 */
@Override
def callback(String provider) {
    WebContext context = new J2EContext(request, response)
    def frontendCallbackUrl
    if (session[CALLBACK_ATTR]) {
        log.debug "Found callback URL in the HTTP session"
        frontendCallbackUrl = session[CALLBACK_ATTR]
    } else {
        log.debug "Found callback URL in the configuration file"
        frontendCallbackUrl = grailsApplication.config.grails.plugin.springsecurity.rest.oauth.frontendCallbackUrl
    }

    try {
        String tokenValue = restOauthService.storeAuthentication(provider, context)

        SecurityContext securityContext = SecurityContextHolder.getContext()
        AccessToken accessToken = (AccessToken) securityContext.getAuthentication()

        def user = tokenStorageService.loadUserByToken(accessToken.refreshToken)

        List<String> cookieChunks  = splitFile(accessToken.refreshToken)

        cookieChunks.eachWithIndex { chunk, index -> 
            def refreshTokenCookie = new Cookie("${index}_refresh_token", chunk);
            refreshTokenCookie.setHttpOnly(true)
            refreshTokenCookie.setMaxAge(43200)//same as expiry config in application.groovy
            refreshTokenCookie.setPath("/ciim/oauth/refreshToken") // global cookie accessible every where
            response.addCookie refreshTokenCookie
        }

        frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, tokenValue)

    } catch (Exception e) {
        log.error("Error during internal access token or refresh token generation:",e)
        def errorParams = new StringBuilder()

        Map params = callbackErrorHandler.convert(e)
        params.each { key, value ->
            errorParams << "&${key}=${value.encodeAsURL()}"
        }

        frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, errorParams.toString())
    }

    log.debug "Redirecting to ${frontendCallbackUrl}"
    redirect url: frontendCallbackUrl
}

private void eraseCookies(HttpServletRequest req, HttpServletResponse resp) {
    Cookie[] cookies = req.getCookies()
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            log.debug("found ${cookie.getName()}")

																						 
 
		   

   
            if(cookie.getName().indexOf("refresh_token") > -1) {
                log.debug("deleting ${cookie.getName()}")
                cookie.setValue("")
                cookie.setHttpOnly(true)
                cookie.setPath("/ciim/oauth/refreshToken")
                cookie.setMaxAge(0)
                resp.addCookie(cookie)
            }
        }
    }
    
}

	 

												 
																						 
					   

//not my code - splits the refresh token as max cookie size is 4096 bytes
public static List<String> splitFile(String data) throws IOException {
    List<String> messages = new ArrayList<>()
    final int CHUNK_SIZE = 2048;// 0.75mb

    byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8)
    byte[] buffer = new byte[CHUNK_SIZE]
    int start = 0
    final int end = CHUNK_SIZE
    ByteArrayInputStream inputStream = new ByteArrayInputStream(dataBytes)

    for (; ; ) {
        int read = inputStream.read(buffer, start, end - start)
        if (read == -1) {
            if (start != 0) {
                messages.add(new String(buffer, 0, start, StandardCharsets.UTF_8))
            }
            break
        }
        // Check for half read multi-byte sequences:
        int fullEnd = start + read
        while (fullEnd > 0) {
            byte b = buffer[fullEnd - 1]
            if (b >= 0) { // ASCII.
                break
            }
            if ((b & 0xC0) == 0xC0) { // Start byte of sequence.
                --fullEnd
                break
            }
            --fullEnd
        }
        messages.add(new String(buffer, 0, fullEnd, StandardCharsets.UTF_8))
        start += read - fullEnd
        if (start > 0) { // Copy the bytes after fullEnd to the start.
            System.arraycopy(buffer, fullEnd, buffer, 0, start)
            //               src     srcI     dest    destI len
        }
    }
    return messages
}

}
`

@jdaugherty
Copy link
Contributor

I'm going to reopen this for further investigation.

@jdaugherty jdaugherty reopened this Sep 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants