grails / grails-spring-security-rest

Grails plugin to implement token-based, RESTful authentication using Spring Security
http://alvarosanchez.github.io/grails-spring-security-rest/
Other
203 stars 117 forks source link

Refresh Token Inaccessible #497

Open MIJohnson opened 1 year ago

MIJohnson commented 1 year ago

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 commented 4 days ago

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 commented 3 days ago

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 commented 3 days ago

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 commented 3 days ago

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 commented 3 days ago

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 commented 1 day ago

I'm going to reopen this for further investigation.