Access application Google Drive account from javascript without client side authorization dialog
Use case
We want to create a regular Google Drive account owned by an application. The application enables users to upload or view files within that account. Since Google Drive API provides javascript library the goal is to use javascript for all the logic (upload and view files) with minimum engagement of server side processing.
For the purpose of creating an application owned account Google provides two options:
-
SERVICE ACCOUNT
Application is authorized by .pk12 certificate; the drawback is that only the application can see documents it created - documents cannot be viewed for example through Google Drive UI. - REGULAR GOOGLE ACCOUNT used by the application only
This option is our choice because we want to access stored files using Google Drive UI too.
More information about the two options is available in Google Drive SDK documentation.
To access a Google account using Google API we have to ensure correct handling of authentication and authorization procedures - Google API supports OAuth2 protocol. To make a long story short: to call Google API from the client side (javascript) we need to associate each request with a valid short term (1 hour expiration) access token (that grants access to the desired Google account). Typical use case when using javascript library is to ask users to grant access for the application to their own documents in their own Google accounts by invoking Google UI authorization dialog. However, our scenario is different: we want to let users transparently upload documents into the application owned account skipping any explicit (UI) authorization steps.
The application can ask Google token endpoint for a new access token if the current one expires only if it has permanent (and stored) refresh token. To get refresh token in advance (read more about offline access) few steps are required:
- Register application (client) in Google API Console
- create
Client ID for web applications
- example:Client ID:xxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com Email address:xxxxxxxxxxxxx@developer.gserviceaccount.com Client secret:xxxxxxxxxxxxxx Redirect URIs: http://google-oauth.cloudfoundry.com/google/auth/callback JavaScript origins: http://google-oauth.cloudfoundry.com
- Retrieve refresh token: ask Google's authentication endpoint for the application authorization code (it redirects to configured redirect URI) and adds authorization code as request parameter. Then exchange the authorization code (and some other parameters) for the access token and refresh token (example of the server side call is explained later in this article). This step may even occur in another application - for those who only need to get their refresh token, I've deployed simple application that can assist in retrieving the refresh token without writing a line of server side code.
- Store permanent refresh token somewhere (simply maybe hardcoded or saved in database).
Now let's look at the step 2 in detail - example of the authorization flow (Java, Spring Framework), required parameters (client id, client secret, redirect uri and request scopes) are read from the HTML form:
@Controller @RequestMapping public class GoogleTokenController { public static final String CALLBACK_URL = "http://{0}/google/auth/callback"; private static final String O_AUTH_CLIENT_SET_UP = "o_auth_client_set_up"; private static final String IS_HUMAN = "isHuman"; private RestTemplate restTemplate; public void setRestTemplate(RestTemplate restTemplate) { this.restTemplate = restTemplate; } /** Displays the form where user can enter client id, client secret and required scopes. */ @RequestMapping(value="/", method=RequestMethod.GET) public ModelAndView clientData(ModelMap model) { model.addAttribute(new OAuthForm()); return new ModelAndView("form", model); } /** * Form submit - collect data from the form and ask Google auth endpoint for the authorization code. * Include UI interaction invoked by Google auth endpoint - it asks to login into the * account you want to access (in our case application account). */ @RequestMapping(value="/", method=RequestMethod.POST) public ModelAndView clientData( @Validated({Default.class, AuthorizationCodeFormCheck.class}) @ModelAttribute OAuthForm form, BindingResult result, HttpServletRequest request, HttpSession session, HttpServletResponse response, ModelMap model) throws IOException { boolean valid = validateRecaptcha(request, model, session); if (!valid) return new ModelAndView("form", model); if (result.hasErrors()) { return new ModelAndView("form"); } // store supplied form data temporary in session session.setAttribute(O_AUTH_CLIENT_SET_UP, form); response.sendRedirect( UriComponentsBuilder.fromUriString("https://accounts.google.com/o/oauth2/auth") .queryParam("response_type", "code") .queryParam("client_id", form.getClientId()) .queryParam("redirect_uri", CALLBACK_URL) // CALLBACK_URL is redirect url configured in API console .queryParam("scope", form.getScopes()) .queryParam("access_type", "offline") .queryParam("approval_prompt", "force") // enable running application in both production and development environments .buildAndExpand(request.getServerName().equals("localhost") ? "localhost:8080/oauth" : request.getServerName()) .toUriString()); return null; } /** * Invoked by Google auth endpoint as soon as UI interaction is finished. * Request parameter code is appended to configured redirect uri. We read the * parameter and exchange it for the access and refresh token. */ @RequestMapping(value="/google/auth/callback",method=RequestMethod.GET) public ModelAndView googleAuthorizationCodeCallback( @RequestParam("code") String code, ModelMap model, HttpSession session, HttpServletRequest request) { OAuthForm form = (OAuthForm) session.getAttribute(O_AUTH_CLIENT_SET_UP); if (form == null) { model.addAttribute(Message.getPlainErrorMessage("Cannot ask for refresh token - no client id, client secret and scopes stored in the session. Please try to fill the form again.")); return new ModelAndView("result", model); } MultiValueMapmap = new LinkedMultiValueMap (); String redirectUri = UriComponentsBuilder.fromUriString(CALLBACK_URL) .buildAndExpand(request.getServerName().equals("localhost") ? "localhost:8080/oauth" : request.getServerName()) .toUriString(); map.add("code", code); map.add("client_id", form.getClientId()); map.add("client_secret", form.getClientSecret()); map.add("redirect_uri", redirectUri); map.add("grant_type", "authorization_code"); // invoke Google token endpoint model.addAttribute("json", restTemplate.postForObject("https://accounts.google.com/o/oauth2/token", map, String.class)); return new ModelAndView("result", model); } /** Ask for new access token - display form (we need again client id, client secret and refresh token). */ @RequestMapping(value="/google/auth/new-access-token",method=RequestMethod.GET) public ModelAndView getNewAccesToken(ModelMap model, HttpSession session) { OAuthForm form = (OAuthForm) session.getAttribute(O_AUTH_CLIENT_SET_UP); if (form == null) form = new OAuthForm(); model.addAttribute(form); return new ModelAndView("newAccessToken",model); } /** Submit new access token form. */ @RequestMapping(value="/google/auth/new-access-token",method=RequestMethod.POST) public ModelAndView getNewAccesToken( @Validated({Default.class, NewAccessTokenFormCheck.class}) @ModelAttribute OAuthForm form, BindingResult result, ModelMap model, HttpSession session, HttpServletRequest request) { boolean valid = validateRecaptcha(request, model, session); if (!valid) return new ModelAndView("newAccessToken", model); if (result.hasErrors()) return new ModelAndView("newAccessToken", model); // update form (include refresh token entered) session.setAttribute(O_AUTH_CLIENT_SET_UP, form); MultiValueMap map = new LinkedMultiValueMap (); map.add("client_id", form.getClientId()); map.add("client_secret", form.getClientSecret()); map.add("refresh_token", form.getRefreshToken()); map.add("grant_type", "refresh_token"); // invoke Google token endpoint model.addAttribute("json", restTemplate.postForObject("https://accounts.google.com/o/oauth2/token", map, String.class)); return new ModelAndView("listFiles", model); } private boolean validateRecaptcha( HttpServletRequest request, ModelMap model, HttpSession session) { // do the validation } }
Try it!
http://google-oauth.cloudfoundry.com
The javascript part
If we have an access token we can for example list files in the application account.
// json is request attribute, expression is evaluated in jsp page before passing it // to javascript var ACCESS_TOKEN = ${json}; /** * Called when the client library is loaded. */ function handleClientLoad() { window.setTimeout(onClientLoaded, 1); } /** * Set access token retrieved on server side. This replaces client side explicit * authentication through authentication dialog. */ function setAccessToken() { gapi.auth.setToken(ACCESS_TOKEN); } /** Load the Drive API. */ function onClientLoaded() { setAccessToken(); gapi.client.load('drive', 'v2', onDriveClientLoaded); } /** Retrive all ads for the current facility. */ function onDriveClientLoaded() { retrieveFiles(); } function retrieveFiles() { var retrievePageOfFiles = function(request, result) { request.execute(function(resp) { result = result.concat(resp.items); var nextPageToken = resp.nextPageToken; if (nextPageToken) { request = gapi.client.drive.files.list({'pageToken': nextPageToken}); retrievePageOfFiles(request, result); } else { console.log(result); console.log(resp); } }); }; var initialRequest = gapi.client.drive.files.list(); retrievePageOfFiles(initialRequest, []); }
The tricky part is to ensure that we always have a valid access token. We can get it only using server side logic and then pass it to javascript. I've encapsulated the check for token's expiration and fresh access token retrieval routine in standalone helper class AccessTokenHolder:
@Component public class AccessTokenHolder implements InitializingBean { private String refreshToken; private String clientId; private String clientSecret; private AccessToken accessToken; @Autowired private RestTemplate restTemplate; @Autowired private Environment env; public void setRestTemplate(RestTemplate restTemplate) { this.restTemplate = restTemplate; } /** Gets valid access token - never null or expired one. */ public AccessToken getAccessToken() { if (accessToken.willExpireIn5Minutes()) aquireNewAccessToken(); return accessToken; } private synchronized void aquireNewAccessToken() { // called by waiting thread, but previous thread already updated access token if (accessToken != null && !accessToken.willExpireIn5Minutes()) return; MultiValueMapmap = new LinkedMultiValueMap (); map.add("client_id", clientId); map.add("client_secret", clientSecret); map.add("refresh_token", refreshToken); map.add("grant_type", "refresh_token"); this.accessToken = restTemplate.postForObject("https://accounts.google.com/o/oauth2/token", map, AccessToken.class); this.accessToken.setupExpiration(); } @Override public void afterPropertiesSet() throws Exception { refreshToken = env.getProperty("oauth.refreshToken"); clientId = env.getProperty("oauth.clientId"); clientSecret = env.getProperty("oauth.clientSecret"); Assert.notNull(refreshToken, "Refresh token must be set in AccessTokenHolder."); Assert.notNull(clientId, "Client id must be set in AccessTokenHolder."); Assert.notNull(clientSecret, "Client secret must be set in AccessTokenHolder."); Assert.notNull(restTemplate, "Rest template must be set in AccessTokenHolder."); aquireNewAccessToken(); Assert.notNull(accessToken, "First access token was not initialized in AccessTokenHolder."); } }
That's it.
Thank you!
ReplyDeleteThis article is exactly what I wanted to know!
Can the server side part be done in php?
ReplyDeleteAdding to the previous comment about doing it using PHP - Sorry I am not familiar with Java and would like to implement this using php. I am not clear about the Google UI authorization dialog. Is it still being shown to the user? If not how is it being handled on the server? Does java help in this regard? Would I be able to do the same with PHP? some more explanation would be very helpful! Thanks!
ReplyDelete