OAuth2 for Apps Script
OAuth2 for Apps Script is a library for Google Apps Script that provides the
+ OAuth2 for Apps Script is a library for Google Apps Script that provides the
ability to create and authorize OAuth2 tokens as well as refresh them when they
-expire. This library uses Apps Script's new
+expire. This library uses Apps Script's
StateTokenBuilder
and This library is already published as an Apps Script, making it easy to include
+ If you are trying to connect to a Google API from Apps Script you might not need
+to use this library at all. Apps Script has a number of easy-to-use,
+built-in services, as well as a variety of
+advanced services that wrap existing Google REST APIs. Even if your API is not covered by either, you can still use Apps Script to
+obtain the OAuth2 token for you. Simply
+edit the script's manifest to
+include the additional scopes that your API requires.
+When the user authorizes your script they will also be asked to approve those
+additional scopes. Then use the method Visit the sample This library is already published as an Apps Script, making it easy to include
in your project. To add it to your script, do the following in the Apps Script
code editor: This library is already published as an Apps Script, making it
Before you can start authenticating against an OAuth2 provider, you usually need
-to register your application and retrieve the client ID and secret. Often
-these registration screens require you to enter a "Redirect URI", which is the
-URL that users will be redirected to after they've authorized the token. For
-this library (and the Apps Script functionality in general) the URL will always
-be in the following format: Where Before you can start authenticating against an OAuth2 provider, you usually need
+to register your application with that OAuth2 provider and obtain a client ID
+and secret. Often a provider's registration screen requires you to enter a
+"Redirect URI", which is the URL that the user's browser will be redirected to
+after they've authorized access to their account at that provider. For this library (and the Apps Script functionality in general) the URL will
+always be in the following format: Where Alternatively you can call the service's Before you can start authenticating against an OAuth2 pr
* Logs the redirect URI to register.
*/
function logRedirectUri() {
- var service = getService();
+ var service = getService_();
Logger.log(service.getRedirectUri());
-} Using the library to generate an OAuth2 token has the following basic steps. The OAuth2Service class contains the configuration information for a given
+}
+
+ Using the library to generate an OAuth2 token has the following basic steps. The OAuth2Service class contains the configuration information for a given
OAuth2 provider, including its endpoints, client IDs and secrets, etc. This
information is not persisted to any data store, so you'll need to create this
object each time you want to use it. The example below shows how to create a
service for the Google Drive API. The OAuth2Service class contains the con
// Sets the login hint, which will prevent the account chooser screen
// from being shown to users logged in with multiple accounts.
- .setParam('login_hint', Session.getActiveUser().getEmail())
+ .setParam('login_hint', Session.getEffectiveUser().getEmail())
// Requests offline access.
.setParam('access_type', 'offline')
- // Forces the approval prompt every time. This is useful for testing,
- // but not desirable in a production application.
- .setParam('approval_prompt', 'force');
-} Apps Script UI's are not allowed to redirect the user's window to a new URL, so
+ // Consent prompt is required to ensure a refresh token is always
+ // returned when requesting offline access.
+ .setParam('prompt', 'consent');
+}
+ Apps Script UI's are not allowed to redirect the user's window to a new URL, so
you'll need to present the authorization URL as a link for the user to click.
The URL is generated by the service, using the function The OAuth2Service class contains the con
} else {
// ...
}
-} When the user completes the OAuth2 flow, the callback function you specified
+}
+
+ When the user completes the OAuth2 flow, the callback function you specified
for your service will be invoked. This callback function should pass its
request object to the service's If the authorization URL was opened by the Apps Script UI (via a link, button,
+}
+
+ If the authorization URL was opened by the Apps Script UI (via a link, button,
etc) it's possible to automatically close the window/tab using
Now that the service is authorized you can use its access token to make
+ Now that the service is authorized you can use its access token to make
requests to the API. The access token can be passed along with a To logout the user or disconnect the service, perhaps so the user can select a
+}
+
+ To logout the user or disconnect the service, perhaps so the user can select a
different account, use the Scripts that use the library heavily should enable caching on the service, so as
+}
+
+ In almost all cases you'll want to persist the OAuth tokens after you retrieve
+them. This prevents having to request access from the user every time you want
+to call the API. To do so, make sure you set a properties store when you define
+your service: Apps Script has property stores scoped to the user, script,
+or document. In most cases you'll want to choose user-scoped properties, as it
+is most common to have each user of your script authorize access to their own
+account. However there are uses cases where you'd want to authorize access to
+a shared resource and then have all users of the script (or on the same
+document) share that access. When using a service account or 2-legged OAuth flow, where users aren't prompted
+for authorization, storing tokens is still beneficial as there can be rate
+limits on generating new tokens. However there are edge cases where you need to
+generate lots of different tokens in a short amount of time, and persisting
+those tokens to properties can exceed your Scripts that use the library heavily should enable caching on the service, so as
to not exhaust their Make sure to select a cache with the same scope (user, script, or document) as
+ // ...
+
+ Make sure to select a cache with the same scope (user, script, or document) as
the property store you configured. A race condition can occur when two or more script executions are both trying to
+ A race condition can occur when two or more script executions are both trying to
refresh an expired token at the same time. This is sometimes observed in
Gmail Add-ons, where a user
quickly paging through their email can trigger the same add-on multiple times. A race condition can occur when two or more script executions
.setPropertyStore(PropertiesService.getUserProperties())
.setCache(CacheService.getUserCache())
.setLock(LockService.getUserLock())
- // ... Make sure to select a lock with the same scope (user, script, or document) as
+ // ...
+
+ Make sure to select a lock with the same scope (user, script, or document) as
the property store and cache you configured. See below for some features of the library you may need to utilize depending on
-the specifics of the OAuth provider you are connecting to. See the generated
+ See below for some features of the library you may need to utilize depending on
+the specifics of the OAuth provider you are connecting to. See the generated
reference documentation
for a complete list of methods available. OAuth services can return a token in two ways: as JSON or an URL encoded
+ OAuth services can return a token in two ways: as JSON or an URL encoded
string. You can set which format the token is in with
Some services, such as the FitBit API, require you to set an Authorization
+ Some services, such as the FitBit API, require you to set an Authorization
header on access token requests. The See the FitBit sample for the complete code. Some OAuth providers, such as the Smartsheet API, require you to
-add a hash to the access token request payloads.
+});
+
+ See the FitBit sample for the complete code. Almost all services use the Some OAuth providers, such as the Smartsheet API, require you to
+add a hash to the access token request payloads.
The See the Smartsheet sample for the complete code. Some OAuth providers return IDs and other critical information in the callback
+.setTokenPayloadHandler(myTokenHandler)
+
+ See the Smartsheet sample for the complete code. Some OAuth providers return IDs and other critical information in the callback
URL along with the authorization code. While it's possible to capture and store
these separately, they often have a lifecycle closely tied to that of the token
and it makes sense to store them together. You can use Some OAuth providers return IDs and other
in the callback URL. In the following code the account ID is extracted from the
request parameters and saved saved into storage. Some OAuth providers return IDs and other
} else {
return HtmlService.createHtmlOutput('Denied.');
}
-} When making an authorized request the account ID is retrieved from storage and
+}
+
+ When making an authorized request the account ID is retrieved from storage and
passed via a header. Some OAuth providers return IDs and other
'User-Agent': 'Apps Script Sample',
'Harvest-Account-Id': accountId
}
- }); Note that calling Note that calling This library supports the service account authorization flow, also known as the
+ There are occasionally cases where you need to preserve some data through the
+OAuth flow, so that it is available in your callback function. Although you
+could use the token storage mechanism discussed above for that purpose, writing
+to the PropertiesService is expensive and not neccessary in the case where the
+user doesn't start or fails to complete the OAuth flow. As an alternative you can store small amounts of data in the OAuth2 These values will be stored along-side Apps Script's internal information in the
+encypted This library supports the service account authorization flow, also known as the
JSON Web Token (JWT) Profile.
This is a two-legged OAuth flow that doesn't require a user to visit a URL and
authorize access. One common use for service accounts with Google APIs is
domain-wide delegation.
-This process allows a Google Apps for Work/EDU domain administrator to grant an
+This process allows a G Suite domain administrator to grant an
application access to all the users within the domain. When the application
wishes to access the resources of a particular user, it uses the service account
authorization flow to obtain an access token. See the sample
This library was designed to work with any OAuth2 provider, but because of small
+ Although optimized for the authorization code (3-legged) and service account
+(JWT bearer) flows, this library supports arbitrary flows using the
+ The most common of these is the See the sample The service name passed in to the Occasionally you may need to make multiple connections to the same API, for
+example if your script is trying to copy data from one account to another. In
+those cases you'll need to devise your own method for creating unique service
+names: You can list all of the service names you've previously stored tokens for using
+ This library was designed to work with any OAuth2 provider, but because of small
differences in how they implement the standard it may be that some APIs
aren't compatible. If you find an API that it doesn't work with, open an issue
or fix the problem yourself and make a pull request against the source code. This library is designed for server-side OAuth flows, and client-side flows with
+implicit grants ( You are setting explicit scopes
+ You are setting explicit scopes
in your manifest file but have forgotten to add the
OAuth2 for Apps Script
+/usercallback endpoint to handle the redirects.Setup
Connecting to a Google API
+ScriptApp.getOAuthToken()
+in your code to access the OAuth2 access token the script has acquired and pass
+it in the Authorization header of a UrlFetchApp.fetch() call.NoLibrary to see an example of how this
+can be done.Setup
+
@@ -67,13 +100,17 @@
Setup
-https://www.googleapis.com/auth/script.external_requestRedirect URI
https://script.google.com/macros/d/{SCRIPT ID}/usercallback{SCRIPT ID} is the ID of the script that is using this library. You
+Redirect URI
+
+https://script.google.com/macros/d/{SCRIPT ID}/usercallback
+{SCRIPT ID} is the ID of the script that is using this library. You
can find your script's ID in the Apps Script code editor by clicking on
the menu item "File > Project properties".getRedirectUri() method to view the
@@ -82,15 +119,19 @@ Redirect URI
Usage
1. Create the OAuth2 service
Usage
+1. Create the OAuth2 service
+
+function getDriveService() {
+function getDriveService_() {
// Create a new service with the given name. The name will be used when
// persisting the authorized token, so ensure it is unique within the
// scope of the property store.
@@ -118,19 +159,22 @@ 1. Create the OAuth2 service
2. Direct the user to the authorization URL
2. Direct the user to the authorization URL
+getAuthorizationUrl().function showSidebar() {
- var driveService = getDriveService();
+ var driveService = getDriveService_();
if (!driveService.hasAccess()) {
var authorizationUrl = driveService.getAuthorizationUrl();
var template = HtmlService.createTemplate(
@@ -142,47 +186,85 @@ 1. Create the OAuth2 service
3. Handle the callback
3. Handle the callback
+handleCallback function, and show a message
to the user.function authCallback(request) {
- var driveService = getDriveService();
+ var driveService = getDriveService_();
var isAuthorized = driveService.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput('Success! You can close this tab.');
} else {
return HtmlService.createHtmlOutput('Denied. You can close this tab');
}
-}window.top.close(). You can see an example of this in the sample add-on's
Callback.html.4. Get the access token
4. Get the access token
+UrlFetchApp
request in the "Authorization" header.function makeRequest() {
- var driveService = getDriveService();
+ var driveService = getDriveService_();
var response = UrlFetchApp.fetch('https://github.com/service/https://www.googleapis.com/drive/v2/files?maxResults=10', {
headers: {
Authorization: 'Bearer ' + driveService.getAccessToken()
}
});
// ...
-}Logout
Logout
+reset() method:function logout() {
- var service = getDriveService()
+ var service = getDriveService_()
service.reset();
-}Best practices
Caching
Best practices
+Token storage
+
+return OAuth2.createService('Foo')
+ .setPropertyStore(PropertiesService.getUserProperties())
+ // ...
+PropertiesService quota. In those
+cases you can omit any form of token storage and just retrieve new ones as
+needed.Caching
+PropertiesService quotas. To enable caching, simply add
a CacheService cache when configuring the service:return OAuth2.createService('Foo')
.setPropertyStore(PropertiesService.getUserProperties())
.setCache(CacheService.getUserCache())
- // ...Locking
Locking
+Locking
Advanced configuration
Advanced configuration
+Setting the token format
Setting the token format
+setTokenFormat(tokenFormat). There are two ENUMS to set the mode:
TOKEN_FORMAT.FORM_URL_ENCODED and TOKEN_FORMAT.JSON. JSON is set as default
if no token format is chosen.Setting additional token headers
Setting additional token headers
+setTokenHeaders() method allows you
to pass in a JavaScript object of additional header key/value pairs to be used
in these requests..setTokenHeaders({
'Authorization': 'Basic ' + Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET)
-});Modifying the access token payload
Setting the token HTTP method
+POST HTTP method when retrieving the access token,
+but a few services deviate from the spec and use the PUT method instead. To
+accomodate those cases you can use the setTokenMethod() method to specify the
+HTTP method to use when making the request.Modifying the access token payload
+setTokenPayloadHandler method allows you to pass in a function to modify
the payload of an access token request before the request is sent to the token
endpoint:// Set the handler for modifying the access token request payload:
-.setTokenPayloadHandler(myTokenHandler)Storing token-related data
Storing token-related data
+Service.getStorage() to
@@ -228,7 +326,7 @@ Storing token-related data
function authCallback(request) {
- var service = getService();
+ var service = getService_();
var authorized = service.handleCallback(request);
if (authorized) {
// Gets the authorized account ID from the scope string. Assumes the
@@ -242,7 +340,9 @@ Storing token-related data
if (service.hasAccess()) {
// Retrieve the account ID from storage.
@@ -254,25 +354,112 @@ Storing token-related data
Service.reset() will remove all custom values from storage,
+ });
+
+Service.reset() will remove all custom values from storage,
in addition to the token.Using service accounts
Passing additional parameters to the callback function
+state
+token, which is a standard mechanism for this purpose. To do so, pass an
+optional hash of parameter names and values to the getAuthorizationUrl()
+method:
+var authorizationUrl = getService_().getAuthorizationUrl({
+ // Pass the additional parameter "lang" with the value "fr".
+ lang: 'fr'
+});
+state token, which is passed in the authorization URL and passed back
+to the redirect URI. The state token is automatically decrypted in the
+callback function and you can access your parameters using the same
+request.parameter field used in web apps:
+function authCallback(request) {
+ var lang = request.parameter.lang;
+ // ...
+}
+Using service accounts
+GoogleServiceAccount.gs for more
information.Compatibility
Using alternative grant types
+setGrantType() method. Use setParam() or setTokenPayloadHandler() to add
+fields to the token request payload, and setTokenHeaders() to add any required
+headers.client_credentials grant type, which often
+requires that the client ID and secret are passed in the Authorization header.
+When using this grant type, if you set a client ID and secret using
+setClientId() and setClientSecret() respectively then an
+Authorization: Basic ... header will be added to the token request
+automatically, since this is what most OAuth2 providers require. If your
+provider uses a different method of authorization then don't set the client ID
+and secret and add an authorization header manually.TwitterAppOnly.gs for a working
+example.Frequently Asked Questions
+How can I connect to multiple OAuth services?
+createService method forms part of the key
+used when storing and retrieving tokens in the property store. To connect to
+multiple services merely ensure they have different service names. Often this
+means selecting a service name that matches the API the user will authorize:
+function run() {
+ var gitHubService = getGitHubService_();
+ var mediumService = getMediumService_();
+ // ...
+}
+
+function getGitHubService_() {
+ return OAuth2.createService('GitHub')
+ // GitHub settings ...
+}
+
+function getMediumService_() {
+ return OAuth2.createService('Medium')
+ // Medium settings ...
+}
+
+function run() {
+ var copyFromService = getGitHubService_('from');
+ var copyToService = getGitHubService_('to');
+ // ...
+}
+
+function getGitHubService_(label) {
+ return OAuth2.createService('GitHub_' + label)
+ // GitHub settings ...
+}
+OAuth2.getServiceNames(propertyStore).Compatibility
+Breaking changes
+
response_type=token) are not supported.Breaking changes
+
Breaking changes
-Service.getToken_() to Service.getToken(), since
there OAuth providers that return important information in the token response.Troubleshooting
You do not have permission to call fetch
Troubleshooting
+You do not have permission to call fetch
+https://www.googleapis.com/auth/script.external_request scope used by this library
(and eventually the UrlFetchApp request you are making to an API).
Troubleshooting
You do not have permission to call fetch
You
OAuth2.gs
+ OAuth2.js
OAuth2.gs
Service.gs
+Service.js
Service.gs
Storage.gs
+Storage.js
Storage.gs
Utilities.gs
+Utilities.js
Utilities.gs
diff --git a/docs/scripts/linenumber.js b/docs/scripts/linenumber.js index 8d52f7ea..8e949198 100644 --- a/docs/scripts/linenumber.js +++ b/docs/scripts/linenumber.js @@ -1,12 +1,28 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed 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. + */ + /*global document */ -(function() { - var source = document.getElementsByClassName('prettyprint source linenums'); - var i = 0; - var lineNumber = 0; - var lineId; - var lines; - var totalLines; - var anchorHash; +(() => { + const source = document.getElementsByClassName('prettyprint source linenums'); + let i = 0; + let lineNumber = 0; + let lineId; + let lines; + let totalLines; + let anchorHash; if (source && source[0]) { anchorHash = document.location.hash.substring(1); @@ -15,7 +31,7 @@ for (; i < totalLines; i++) { lineNumber++; - lineId = 'line' + lineNumber; + lineId = `line${lineNumber}`; lines[i].id = lineId; if (lineId === anchorHash) { lines[i].className += ' selected'; diff --git a/docs/scripts/prettify/lang-css.js b/docs/scripts/prettify/lang-css.js index 041e1f59..4504d176 100644 --- a/docs/scripts/prettify/lang-css.js +++ b/docs/scripts/prettify/lang-css.js @@ -1,2 +1,18 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed 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. + */ + PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n"]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com", /^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]); diff --git a/docs/scripts/prettify/prettify.js b/docs/scripts/prettify/prettify.js index eef5ad7e..61409473 100644 --- a/docs/scripts/prettify/prettify.js +++ b/docs/scripts/prettify/prettify.js @@ -1,3 +1,19 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed 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. + */ + var q=null;window.PR_SHOULD_USE_CONTINUATION=!0; (function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a= [],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;c
= title ?>
+ if (error) { ?> +An error has occurred: = error ?>.
+You may close this tab.
+ } else if (isSignedIn) { ?> +Signed in! + You may close this tab.
+ } else { ?> +Sign in failed. + You may close this tab.
+ } ?> + + + + diff --git a/samples/WebApp/Code.gs b/samples/WebApp/Code.gs new file mode 100644 index 00000000..eb2fbe2f --- /dev/null +++ b/samples/WebApp/Code.gs @@ -0,0 +1,146 @@ +/** + * Copyright 2018 Google LLC + * + * Licensed 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 + * + * https://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. + */ + +/* + * The GitHub OAuth2 client ID and secret. Uncomment and paste values from the + * GitHub developer settings. + */ +// var CLIENT_ID = '...'; +// var CLIENT_SECRET = '...'; + +/** + * Handles GET requests. The page structure is described in the Page.html + * project file. + */ +function doGet() { + var service = getGitHubService_(); + var template = HtmlService.createTemplateFromFile('Page'); + template.email = Session.getEffectiveUser().getEmail(); + template.isSignedIn = service.hasAccess(); + return template.evaluate() + .setTitle('Sample Web App') + .setSandboxMode(HtmlService.SandboxMode.IFRAME); +} + +/** + * Builds and returns the authorization URL from the service object. + * @return {String} The authorization URL. + */ +function getAuthorizationUrl() { + return getGitHubService_().getAuthorizationUrl(); +} + +/** + * Resets the API service, forcing re-authorization before + * additional authorization-required API calls can be made. + */ +function signOut() { + getGitHubService_().reset(); +} + +/** + * Gets the user's GitHub profile. + */ +function getGitHubProfile() { + return getGitHubResource('user'); +} + +/** + * Gets the user's GitHub repos. + */ +function getGitHubRepos() { + return getGitHubResource('user/repos'); +} + +/** + * Fetches the specified resource from the GitHub API. + */ +function getGitHubResource(resource) { + var service = getGitHubService_(); + if (!service.hasAccess()) { + throw new Error('Error: Missing GitHub authorization.'); + } + var url = 'https://api.github.com/' + resource; + var response = UrlFetchApp.fetch(url, { + headers: { + Authorization: 'Bearer ' + service.getAccessToken() + } + }); + return JSON.parse(response.getContentText()); +} + +/** + * Gets an OAuth2 service configured for the GitHub API. + * @return {OAuth2.Service} The OAuth2 service + */ +function getGitHubService_() { + return OAuth2.createService('github') + // Set the endpoint URLs. + .setAuthorizationBaseUrl('https://github.com/login/oauth/authorize') + .setTokenUrl('https://github.com/login/oauth/access_token') + + // Set the client ID and secret. + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + + // Set the name of the callback function that should be invoked to + // complete the OAuth flow. + .setCallbackFunction('authCallback') + + // Set the property store where authorized tokens should be persisted. + .setPropertyStore(PropertiesService.getUserProperties()); +} + +/** + * Callback handler that is executed after an authorization attempt. + * @param {Object} request The results of API auth request. + */ +function authCallback(request) { + var template = HtmlService.createTemplateFromFile('Callback'); + template.email = Session.getEffectiveUser().getEmail(); + template.isSignedIn = false; + template.error = null; + var title; + try { + var service = getGitHubService_(); + var authorized = service.handleCallback(request); + template.isSignedIn = authorized; + title = authorized ? 'Access Granted' : 'Access Denied'; + } catch (e) { + template.error = e; + title = 'Access Error'; + } + template.title = title; + return template.evaluate() + .setTitle(title) + .setSandboxMode(HtmlService.SandboxMode.IFRAME); +} + +/** + * Logs the redict URI to register in the Google Developers Console. + */ +function logRedirectUri() { + Logger.log(OAuth2.getRedirectUri()); +} + +/** + * Includes the given project HTML file in the current HTML project file. + * Also used to include JavaScript. + * @param {String} filename Project file name. + */ +function include(filename) { + return HtmlService.createHtmlOutputFromFile(filename).getContent(); +} diff --git a/samples/WebApp/JavaScript.html b/samples/WebApp/JavaScript.html new file mode 100644 index 00000000..769067fb --- /dev/null +++ b/samples/WebApp/JavaScript.html @@ -0,0 +1,114 @@ + + diff --git a/samples/WebApp/Page.html b/samples/WebApp/Page.html new file mode 100644 index 00000000..27d31127 --- /dev/null +++ b/samples/WebApp/Page.html @@ -0,0 +1,45 @@ + + + + +Please sign in to your GitHub account to continue.
+ +Hello {{sample.user.login || '...' }}
+ +Repos:
+handleCallback() method
* to complete the process.
* @param {string} callbackFunctionName The name of the callback function.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
*/
Service_.prototype.setCallbackFunction = function(callbackFunctionName) {
this.callbackFunctionName_ = callbackFunctionName;
@@ -155,7 +207,7 @@ Service_.prototype.setCallbackFunction = function(callbackFunctionName) {
* the Script Editor, and then click on the link "Google Developers Console" in
* the resulting dialog.
* @param {string} clientId The client ID to use for the OAuth flow.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
*/
Service_.prototype.setClientId = function(clientId) {
this.clientId_ = clientId;
@@ -167,7 +219,7 @@ Service_.prototype.setClientId = function(clientId) {
* documentation for setClientId() for more information on how to
* create client IDs and secrets.
* @param {string} clientSecret The client secret to use for the OAuth flow.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
*/
Service_.prototype.setClientSecret = function(clientSecret) {
this.clientSecret_ = clientSecret;
@@ -180,7 +232,7 @@ Service_.prototype.setClientSecret = function(clientSecret) {
* may be appropriate if you want to share access across users.
* @param {PropertiesService.Properties} propertyStore The property store to use
* when persisting credentials.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
* @see https://developers.google.com/apps-script/reference/properties/
*/
Service_.prototype.setPropertyStore = function(propertyStore) {
@@ -195,7 +247,7 @@ Service_.prototype.setPropertyStore = function(propertyStore) {
* may be appropriate if you want to share access across users.
* @param {CacheService.Cache} cache The cache to use when persisting
* credentials.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
* @see https://developers.google.com/apps-script/reference/cache/
*/
Service_.prototype.setCache = function(cache) {
@@ -209,7 +261,7 @@ Service_.prototype.setCache = function(cache) {
* stored credentials at a time. This can prevent race conditions that arise
* when two executions attempt to refresh an expired token.
* @param {LockService.Lock} lock The lock to use when accessing credentials.
- * @return {Service_} This service, for chaining.
+ * @return {!Service_} This service, for chaining.
* @see https://developers.google.com/apps-script/reference/lock/
*/
Service_.prototype.setLock = function(lock) {
@@ -224,7 +276,7 @@ Service_.prototype.setLock = function(lock) {
* @param {string|Array.null is used to to store the token and should not
* be used.
- * @return {Storage} The service's storage.
+ * @return {Storage_} The service's storage.
*/
Service_.prototype.getStorage = function() {
- validate_({
- 'Property store': this.propertyStore_
- });
if (!this.storage_) {
- var prefix = 'oauth2.' + this.serviceName_;
+ var prefix = STORAGE_PREFIX_ + this.serviceName_;
this.storage_ = new Storage_(prefix, this.propertyStore_, this.cache_);
}
return this.storage_;
@@ -572,26 +722,70 @@ Service_.prototype.saveToken_ = function(token) {
/**
* Gets the token from the service's property store or cache.
+ * @param {boolean?} optSkipMemoryCheck If true, bypass the local memory cache
+ * when fetching the token.
* @return {Object} The token, or null if no token was found.
*/
-Service_.prototype.getToken = function() {
- return this.getStorage().getValue(null);
+Service_.prototype.getToken = function(optSkipMemoryCheck) {
+ // Gets the stored value under the null key, which is reserved for the token.
+ return this.getStorage().getValue(null, optSkipMemoryCheck);
};
/**
- * Determines if a retrieved token is still valid.
+ * Determines if a retrieved token is still valid. This will return false if
+ * either the authorization token or the ID token has expired.
* @param {Object} token The token to validate.
* @return {boolean} True if it has expired, false otherwise.
* @private
*/
Service_.prototype.isExpired_ = function(token) {
- var expiresIn = token.expires_in || token.expires;
- if (!expiresIn) {
- return false;
- } else {
+ var expired = false;
+ var now = getTimeInSeconds_(new Date());
+
+ // Check the authorization token's expiration.
+ if (token.expiresAt) {
+ if (token.expiresAt - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
+ expired = true;
+ }
+ }
+
+ // Previous code path, provided for migration purpose, can be removed later
+ var expiresIn = token.expires_in_sec || token.expires_in || token.expires;
+ if (expiresIn) {
var expiresTime = token.granted_time + Number(expiresIn);
+ if (expiresTime - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
+ expired = true;
+ }
+ }
+
+ // Check the ID token's expiration, if it exists.
+ if (token.id_token) {
+ var payload = decodeJwt_(token.id_token);
+ if (payload.exp &&
+ payload.exp - now < Service_.EXPIRATION_BUFFER_SECONDS_) {
+ expired = true;
+ }
+ }
+
+ return expired;
+};
+
+/**
+ * Determines if a retrieved token can be refreshed.
+ * @param {Object} token The token to inspect.
+ * @return {boolean} True if it can be refreshed, false otherwise.
+ * @private
+ */
+Service_.prototype.canRefresh_ = function(token) {
+ if (!token.refresh_token) return false;
+ this.ensureExpiresAtSet_(token);
+ if (token.refreshTokenExpiresAt === undefined) {
+ return true;
+ } else {
var now = getTimeInSeconds_(new Date());
- return expiresTime - now < Service_.EXPIRATION_BUFFER_SECONDS_;
+ return (
+ token.refreshTokenExpiresAt - now > Service_.EXPIRATION_BUFFER_SECONDS_
+ );
}
};
@@ -605,22 +799,11 @@ Service_.prototype.exchangeJwt_ = function() {
'Token URL': this.tokenUrl_
});
var jwt = this.createJwt_();
- var headers = {
- 'Accept': this.tokenFormat_
+ var payload = {
+ assertion: jwt,
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer'
};
- if (this.tokenHeaders_) {
- headers = extend_(headers, this.tokenHeaders_);
- }
- var response = UrlFetchApp.fetch(this.tokenUrl_, {
- method: 'post',
- headers: headers,
- payload: {
- assertion: jwt,
- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer'
- },
- muteHttpExceptions: true
- });
- var token = this.getTokenFromResponse_(response);
+ var token = this.fetchToken_(payload);
this.saveToken_(token);
};
@@ -636,10 +819,6 @@ Service_.prototype.createJwt_ = function() {
'Token URL': this.tokenUrl_,
'Issuer or Client ID': this.issuer_ || this.clientId_
});
- var header = {
- alg: 'RS256',
- typ: 'JWT'
- };
var now = new Date();
var expires = new Date(now.getTime());
expires.setMinutes(expires.getMinutes() + this.expirationMinutes_);
@@ -655,12 +834,13 @@ Service_.prototype.createJwt_ = function() {
if (this.params_.scope) {
claimSet.scope = this.params_.scope;
}
- var toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' +
- Utilities.base64EncodeWebSafe(JSON.stringify(claimSet));
- var signatureBytes =
- Utilities.computeRsaSha256Signature(toSign, this.privateKey_);
- var signature = Utilities.base64EncodeWebSafe(signatureBytes);
- return toSign + '.' + signature;
+ if (this.additionalClaims_) {
+ var additionalClaims = this.additionalClaims_;
+ Object.keys(additionalClaims).forEach(function(key) {
+ claimSet[key] = additionalClaims[key];
+ });
+ }
+ return encodeJwt_(claimSet, this.privateKey_);
};
/**
@@ -681,3 +861,35 @@ Service_.prototype.lockable_ = function(func) {
}
return result;
};
+
+/**
+ * Obtain an access token using the custom grant type specified. Most often
+ * this will be "client_credentials", and a client ID and secret are set an
+ * "Authorization: Basic ..." header will be added using those values.
+ */
+Service_.prototype.exchangeGrant_ = function() {
+ validate_({
+ 'Grant Type': this.grantType_,
+ 'Token URL': this.tokenUrl_
+ });
+ var payload = {
+ grant_type: this.grantType_
+ };
+ payload = extend_(payload, this.params_);
+
+ // For the client_credentials grant type, add a basic authorization header:
+ // - If the client ID and client secret are set.
+ // - No authorization header has been set yet.
+ var lowerCaseHeaders = toLowerCaseKeys_(this.tokenHeaders_);
+ if (this.grantType_ === 'client_credentials' &&
+ this.clientId_ &&
+ this.clientSecret_ &&
+ (!lowerCaseHeaders || !lowerCaseHeaders.authorization)) {
+ this.tokenHeaders_ = this.tokenHeaders_ || {};
+ this.tokenHeaders_.authorization = 'Basic ' +
+ Utilities.base64Encode(this.clientId_ + ':' + this.clientSecret_);
+ }
+
+ var token = this.fetchToken_(payload);
+ this.saveToken_(token);
+};
diff --git a/src/Storage.js b/src/Storage.js
index 651c5b71..a869538e 100644
--- a/src/Storage.js
+++ b/src/Storage.js
@@ -21,14 +21,14 @@
* related information.
* @param {string} prefix The prefix to use for keys in the properties and
* cache.
- * @param {PropertiesService.Properties} properties The properties instance to
- * use.
+ * @param {PropertiesService.Properties} optProperties The optional properties
+ * instance to use.
* @param {CacheService.Cache} [optCache] The optional cache instance to use.
* @constructor
*/
-function Storage_(prefix, properties, optCache) {
+function Storage_(prefix, optProperties, optCache) {
this.prefix_ = prefix;
- this.properties_ = properties;
+ this.properties_ = optProperties;
this.cache_ = optCache;
this.memory_ = {};
}
@@ -40,40 +40,64 @@ function Storage_(prefix, properties, optCache) {
*/
Storage_.CACHE_EXPIRATION_TIME_SECONDS = 21600; // 6 hours.
+/**
+ * The special value to use in the cache to indicate that there is no value.
+ * @type {string}
+ * @private
+ */
+Storage_.CACHE_NULL_VALUE = '__NULL__';
+
/**
* Gets a stored value.
* @param {string} key The key.
+ * @param {boolean?} optSkipMemoryCheck Whether to bypass the local memory cache
+ * when fetching the value (the default is false).
* @return {*} The stored value.
*/
-Storage_.prototype.getValue = function(key) {
- // Check memory.
- if (this.memory_[key]) {
- return this.memory_[key];
- }
-
+Storage_.prototype.getValue = function(key, optSkipMemoryCheck) {
var prefixedKey = this.getPrefixedKey_(key);
var jsonValue;
var value;
+ if (!optSkipMemoryCheck) {
+ // Check in-memory cache.
+ if (value = this.memory_[prefixedKey]) {
+ if (value === Storage_.CACHE_NULL_VALUE) {
+ return null;
+ }
+ return value;
+ }
+ }
+
// Check cache.
if (this.cache_ && (jsonValue = this.cache_.get(prefixedKey))) {
value = JSON.parse(jsonValue);
- this.memory_[key] = value;
+ this.memory_[prefixedKey] = value;
+ if (value === Storage_.CACHE_NULL_VALUE) {
+ return null;
+ }
return value;
}
// Check properties.
- if (jsonValue = this.properties_.getProperty(prefixedKey)) {
+ if (this.properties_ &&
+ (jsonValue = this.properties_.getProperty(prefixedKey))) {
if (this.cache_) {
this.cache_.put(prefixedKey,
jsonValue, Storage_.CACHE_EXPIRATION_TIME_SECONDS);
}
value = JSON.parse(jsonValue);
- this.memory_[key] = value;
+ this.memory_[prefixedKey] = value;
return value;
}
- // Not found.
+ // Not found. Store a special null value in the memory and cache to reduce
+ // hits on the PropertiesService.
+ this.memory_[prefixedKey] = Storage_.CACHE_NULL_VALUE;
+ if (this.cache_) {
+ this.cache_.put(prefixedKey, JSON.stringify(Storage_.CACHE_NULL_VALUE),
+ Storage_.CACHE_EXPIRATION_TIME_SECONDS);
+ }
return null;
};
@@ -85,12 +109,14 @@ Storage_.prototype.getValue = function(key) {
Storage_.prototype.setValue = function(key, value) {
var prefixedKey = this.getPrefixedKey_(key);
var jsonValue = JSON.stringify(value);
- this.properties_.setProperty(prefixedKey, jsonValue);
+ if (this.properties_) {
+ this.properties_.setProperty(prefixedKey, jsonValue);
+ }
if (this.cache_) {
this.cache_.put(prefixedKey, jsonValue,
Storage_.CACHE_EXPIRATION_TIME_SECONDS);
}
- this.memory_[key] = value;
+ this.memory_[prefixedKey] = value;
};
/**
@@ -99,11 +125,39 @@ Storage_.prototype.setValue = function(key, value) {
*/
Storage_.prototype.removeValue = function(key) {
var prefixedKey = this.getPrefixedKey_(key);
- this.properties_.deleteProperty(prefixedKey);
+ this.removeValueWithPrefixedKey_(prefixedKey);
+};
+
+/**
+ * Resets the storage, removing all stored data.
+ * @param {string} key The key.
+ */
+Storage_.prototype.reset = function() {
+ var prefix = this.getPrefixedKey_();
+ var prefixedKeys = Object.keys(this.memory_);
+ if (this.properties_) {
+ var props = this.properties_.getProperties();
+ prefixedKeys = Object.keys(props).filter(function(prefixedKey) {
+ return prefixedKey === prefix || prefixedKey.indexOf(prefix + '.') === 0;
+ });
+ }
+ for (var i = 0; i < prefixedKeys.length; i++) {
+ this.removeValueWithPrefixedKey_(prefixedKeys[i]);
+ };
+};
+
+/**
+ * Removes a stored value.
+ * @param {string} prefixedKey The key.
+ */
+Storage_.prototype.removeValueWithPrefixedKey_ = function(prefixedKey) {
+ if (this.properties_) {
+ this.properties_.deleteProperty(prefixedKey);
+ }
if (this.cache_) {
this.cache_.remove(prefixedKey);
}
- delete this.memory_[key];
+ delete this.memory_[prefixedKey];
};
/**
diff --git a/src/Utilities.js b/src/Utilities.js
index 54b7fc28..dfcbd0fc 100644
--- a/src/Utilities.js
+++ b/src/Utilities.js
@@ -42,7 +42,7 @@ function validate_(params) {
Object.keys(params).forEach(function(name) {
var value = params[name];
if (!value) {
- throw Utilities.formatString('%s is required.', name);
+ throw new Error(name + ' is required.');
}
});
}
@@ -51,7 +51,7 @@ function validate_(params) {
/**
* Gets the time in seconds, rounded down to the nearest second.
* @param {Date} date The Date object to convert.
- * @return {Number} The number of seconds since the epoch.
+ * @return {number} The number of seconds since the epoch.
* @private
*/
function getTimeInSeconds_(date) {
@@ -76,3 +76,94 @@ function extend_(destination, source) {
}
return destination;
}
+
+/* exported toLowerCaseKeys_ */
+/**
+ * Gets a copy of an object with all the keys converted to lower-case strings.
+ *
+ * @param {Object} obj The object to copy.
+ * @return {Object} A shallow copy of the object with all lower-case keys.
+ */
+function toLowerCaseKeys_(obj) {
+ if (obj === null || typeof obj !== 'object') {
+ return obj;
+ }
+ // For each key in the source object, add a lower-case version to a new
+ // object, and return it.
+ return Object.keys(obj).reduce(function(result, k) {
+ result[k.toLowerCase()] = obj[k];
+ return result;
+ }, {});
+}
+
+/* exported encodeJwt_ */
+/**
+ * Encodes and signs a JWT.
+ *
+ * @param {Object} payload The JWT payload.
+ * @param {string} key The key to use when generating the signature.
+ * @return {string} The encoded and signed JWT.
+ */
+function encodeJwt_(payload, key) {
+ var header = {
+ alg: 'RS256',
+ typ: 'JWT'
+ };
+ var toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' +
+ Utilities.base64EncodeWebSafe(JSON.stringify(payload));
+ var signatureBytes =
+ Utilities.computeRsaSha256Signature(toSign, key);
+ var signature = Utilities.base64EncodeWebSafe(signatureBytes);
+ return toSign + '.' + signature;
+}
+
+/* exported decodeJwt_ */
+/**
+ * Decodes and returns the parts of the JWT. The signature is not verified.
+ *
+ * @param {string} jwt The JWT to decode.
+ * @return {Object} The decoded payload.
+ */
+function decodeJwt_(jwt) {
+ var payload = jwt.split('.')[1];
+ var blob = Utilities.newBlob(Utilities.base64DecodeWebSafe(payload));
+ return JSON.parse(blob.getDataAsString());
+}
+
+/* exported encodeUrlSafeBase64NoPadding_ */
+/**
+ * Wrapper around base64 encoded to strip padding.
+ * @param {string} value
+ * @return {string} Web safe base64 encoded with padding removed.
+ */
+function encodeUrlSafeBase64NoPadding_(value) {
+ let encodedValue = Utilities.base64EncodeWebSafe(value);
+ encodedValue = encodedValue.slice(0, encodedValue.indexOf('='));
+ return encodedValue;
+}
+
+/* exported encodeChallenge_ */
+/**
+ * Encodes a challenge string for PKCE.
+ *
+ * @param {string} method Encoding method (S256 or plain)
+ * @param {string} codeVerifier String to encode
+ * @return {string} BASE64(SHA256(ASCII(codeVerifier)))
+ */
+function encodeChallenge_(method, codeVerifier) {
+ method = method.toLowerCase();
+
+ if (method === 'plain') {
+ return codeVerifier;
+ }
+
+ if (method === 's256') {
+ const hashedValue = Utilities.computeDigest(
+ Utilities.DigestAlgorithm.SHA_256,
+ codeVerifier,
+ Utilities.Charset.US_ASCII);
+ return encodeUrlSafeBase64NoPadding_(hashedValue);
+ }
+
+ throw new Error('Unsupported challenge method: ' + method);
+}
diff --git a/src/appsscript.json b/src/appsscript.json
index 8593c7a7..fcba34da 100644
--- a/src/appsscript.json
+++ b/src/appsscript.json
@@ -1,11 +1,4 @@
{
"timeZone": "America/New_York",
- "dependencies": {
- "libraries": [{
- "userSymbol": "Underscore",
- "libraryId": "1I21uLOwDKdyF3_W_hvh6WXiIKWJWno8yG9lB8lf1VBnZFQ6jAAhyNTRG",
- "version": "23"
- }]
- },
"exceptionLogging": "STACKDRIVER"
-}
\ No newline at end of file
+}
diff --git a/test/.eslintrc.js b/test/.eslintrc.js
index b7305192..4f7799ba 100644
--- a/test/.eslintrc.js
+++ b/test/.eslintrc.js
@@ -1,3 +1,19 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
module.exports = {
"rules": {
"require-jsdoc": "off"
diff --git a/test/mocks/blob.js b/test/mocks/blob.js
new file mode 100644
index 00000000..6a83c845
--- /dev/null
+++ b/test/mocks/blob.js
@@ -0,0 +1,25 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
+var MockBlob = function(buffer) {
+ this.buffer = buffer;
+};
+
+MockBlob.prototype.getDataAsString = function() {
+ return this.buffer.toString();
+};
+
+module.exports = MockBlob;
diff --git a/test/mocks/cache.js b/test/mocks/cache.js
index 25dbc100..7e5a00c3 100644
--- a/test/mocks/cache.js
+++ b/test/mocks/cache.js
@@ -1,3 +1,19 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
var MockCache = function(optCache) {
this.store = optCache || {};
this.counter = 0;
diff --git a/test/mocks/lock.js b/test/mocks/lock.js
index 8921b40e..8c68b27d 100644
--- a/test/mocks/lock.js
+++ b/test/mocks/lock.js
@@ -1,17 +1,33 @@
/**
- * @file Mocks out Apps Script's LockService.Lock, using the fibers library to
- * emulate concurrent executions. Like Apps Script's locks, only one execution
- * can hold the lock at a time.
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
*/
-var Fiber = require('fibers');
+/**
+ * @file Mocks out Apps Script's LockService.Lock. Does not implement
+ * correct lock semantics at the moment. Previous versions relied on
+ * node-fibers which is now obsolete. It's possible to recreate apps
+ * script multi-threaded environment via worker threads and implement
+ * proper locking and that may be considered in the future.
+ */
var locked = false;
-var waitingFibers = [];
var MockLock = function() {
this.hasLock_ = false;
this.id = Math.random();
+ this.counter = 0;
};
MockLock.prototype.waitLock = function(timeoutInMillis) {
@@ -20,10 +36,8 @@ MockLock.prototype.waitLock = function(timeoutInMillis) {
if (!locked || this.hasLock_) {
locked = true;
this.hasLock_ = true;
+ this.counter++;
return;
- } else {
- waitingFibers.push(Fiber.current);
- Fiber.yield();
}
} while (new Date().getTime() - start.getTime() < timeoutInMillis);
throw new Error('Unable to get lock');
@@ -32,9 +46,6 @@ MockLock.prototype.waitLock = function(timeoutInMillis) {
MockLock.prototype.releaseLock = function() {
locked = false;
this.hasLock_ = false;
- if (waitingFibers.length) {
- waitingFibers.pop().run();
- }
};
MockLock.prototype.hasLock = function() {
diff --git a/test/mocks/properties.js b/test/mocks/properties.js
index ff3f6ff1..e7ae3be0 100644
--- a/test/mocks/properties.js
+++ b/test/mocks/properties.js
@@ -1,3 +1,19 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
var MockProperties = function(optStore) {
this.store = optStore || {};
this.counter = 0;
@@ -16,4 +32,8 @@ MockProperties.prototype.deleteProperty = function(key) {
delete this.store[key];
};
+MockProperties.prototype.getProperties = function() {
+ return Object.assign({}, this.store);
+};
+
module.exports = MockProperties;
diff --git a/test/mocks/script.js b/test/mocks/script.js
new file mode 100644
index 00000000..615e791c
--- /dev/null
+++ b/test/mocks/script.js
@@ -0,0 +1,50 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
+var MockScriptApp = function() {};
+
+MockScriptApp.prototype.getScriptId = function() {
+ return Math.random().toString(36).substring(2);
+};
+
+MockScriptApp.prototype.newStateToken = function() {
+ return new MockStateTokenBuilder();
+};
+
+var MockStateTokenBuilder = function() {
+ this.arguments = {};
+};
+
+MockStateTokenBuilder.prototype.withMethod = function(method) {
+ this.method = method;
+ return this;
+};
+
+MockStateTokenBuilder.prototype.withArgument = function(key, value) {
+ this.arguments[key] = value;
+ return this;
+};
+
+MockStateTokenBuilder.prototype.withTimeout = function(timeout) {
+ this.timeout = timeout;
+ return this;
+};
+
+MockStateTokenBuilder.prototype.createToken = function() {
+ return JSON.stringify(this);
+};
+
+module.exports = MockScriptApp;
diff --git a/test/mocks/urlfetchapp.js b/test/mocks/urlfetchapp.js
index 999ac636..3b7a7784 100644
--- a/test/mocks/urlfetchapp.js
+++ b/test/mocks/urlfetchapp.js
@@ -1,33 +1,33 @@
/**
- * @file Mocks out Apps Script's UrlFetchApp, using the fibers library to
- * emulate concurrent executions.
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
*/
-var Future = require('fibers/future');
+/**
+ * @file Mocks out Apps Script's UrlFetchApp.
+ */
var MockUrlFetchApp = function() {
- this.delayFunction = () => 0;
this.resultFunction = () => '';
};
MockUrlFetchApp.prototype.fetch = function(url, optOptions) {
- var delay = this.delayFunction();
- var result = this.resultFunction();
- if (delay) {
- sleep(delay).wait();
- }
+ var result = this.resultFunction(url, optOptions);
return {
getContentText: () => result,
getResponseCode: () => 200
};
};
-function sleep(ms) {
- var future = new Future();
- setTimeout(function() {
- future.return();
- }, ms);
- return future;
-}
-
module.exports = MockUrlFetchApp;
diff --git a/test/mocks/utilities.js b/test/mocks/utilities.js
new file mode 100644
index 00000000..5627e81a
--- /dev/null
+++ b/test/mocks/utilities.js
@@ -0,0 +1,60 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
+var MockBlob = require('./blob');
+var URLSafeBase64 = require('urlsafe-base64');
+var crypto = require('crypto');
+
+var MockUtilities = function(optCache) {
+ this.store = optCache || {};
+ this.counter = 0;
+};
+
+MockUtilities.prototype.base64Encode = function(data) {
+ return Buffer.from(data).toString('base64');
+};
+
+MockUtilities.prototype.base64EncodeWebSafe = function(data) {
+ return URLSafeBase64.encode(Buffer.from(data));
+};
+
+MockUtilities.prototype.base64DecodeWebSafe = function(data) {
+ return URLSafeBase64.decode(data);
+};
+
+MockUtilities.prototype.computeRsaSha256Signature = function(data, key) {
+ return Math.random().toString(36);
+};
+
+MockUtilities.prototype.newBlob = function(data) {
+ return new MockBlob(data);
+};
+
+MockUtilities.prototype.DigestAlgorithm = {
+ SHA_256: 'sha256'
+};
+
+MockUtilities.prototype.Charset = {
+ US_ASCII: 'us_ascii'
+};
+
+MockUtilities.prototype.computeDigest = function(algorithm, data, charSet) {
+ const hash = crypto.createHash(algorithm);
+ hash.update(data);
+ return hash.digest('utf8');
+};
+
+module.exports = MockUtilities;
diff --git a/test/test.js b/test/test.js
index b5df01cd..252d6d29 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1,25 +1,84 @@
+/**
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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.
+ */
+
var assert = require('chai').assert;
var gas = require('gas-local');
var MockUrlFetchApp = require('./mocks/urlfetchapp');
var MockProperties = require('./mocks/properties');
var MockCache = require('./mocks/cache');
var MockLock = require('./mocks/lock');
-var Future = require('fibers/future');
-
+var MockScriptApp = require('./mocks/script');
+var MockUtilities = require('./mocks/utilities');
var mocks = {
- ScriptApp: {
- getScriptId: function() {
- return '12345';
- }
- },
+ ScriptApp: new MockScriptApp(),
UrlFetchApp: new MockUrlFetchApp(),
+ Utilities: new MockUtilities(),
__proto__: gas.globalMockDefault
};
var OAuth2 = gas.require('./src', mocks);
-describe('Service', function() {
- describe('#getToken()', function() {
- it('should return null when no token is stored', function() {
+describe('OAuth2', () => {
+ describe('#getServiceNames()', () => {
+ it('should return the service names for stored tokens', () => {
+ var props = new MockProperties({
+ 'oauth2.foo': '{"access_token": "abc"}',
+ 'oauth2.bar': '{"access_token": "abc"}',
+ });
+
+ var names = OAuth2.getServiceNames(props);
+
+ assert.deepEqual(names, ['foo', 'bar']);
+ });
+
+ it('should return an empty array when no tokens are stored', () => {
+ var props = new MockProperties();
+
+ var names = OAuth2.getServiceNames(props);
+
+ assert.deepEqual(names, []);
+ });
+
+ it('should ignore keys without a service name', () => {
+ var props = new MockProperties({
+ 'oauth2.': 'foo',
+ 'oauth2..bar': 'bar',
+ });
+
+ var names = OAuth2.getServiceNames(props);
+
+ assert.deepEqual(names, []);
+ });
+
+ it('should not have duplicate names when there are custom keys', () => {
+ var props = new MockProperties({
+ 'oauth2.foo': '{"access_token": "abc"}',
+ 'oauth2.foo.extra': 'my extra stuff',
+ 'oauth2.foo.extra2': 'more extra stuff',
+ });
+
+ var names = OAuth2.getServiceNames(props);
+
+ assert.deepEqual(names, ['foo']);
+ });
+ });
+});
+
+describe('Service', () => {
+ describe('#getToken()', () => {
+ it('should return null when no token is stored', () => {
var service = OAuth2.createService('test')
.setPropertyStore(new MockProperties())
.setCache(new MockCache());
@@ -27,7 +86,7 @@ describe('Service', function() {
assert.equal(service.getToken(), null);
});
- it('should return null after the service is reset', function() {
+ it('should return null after the service is reset', () => {
var service = OAuth2.createService('test')
.setPropertyStore(new MockProperties())
.setCache(new MockCache());
@@ -41,7 +100,7 @@ describe('Service', function() {
assert.equal(service.getToken(), null);
});
- it('should load from the cache', function() {
+ it('should load from the cache', () => {
var token = {
access_token: 'foo'
};
@@ -54,7 +113,7 @@ describe('Service', function() {
assert.deepEqual(service.getToken(), token);
});
- it('should load from the properties and set the cache', function() {
+ it('should load from the properties and set the cache', () => {
var token = {
access_token: 'foo'
};
@@ -70,8 +129,7 @@ describe('Service', function() {
assert.deepEqual(JSON.parse(cache.get('oauth2.test')), token);
});
- it('should not hit the cache or properties on subsequent calls',
- function() {
+ it('should not hit the cache or properties on subsequent calls', () => {
var cache = new MockCache();
var properties = new MockProperties({
'oauth2.test': JSON.stringify({
@@ -91,10 +149,61 @@ describe('Service', function() {
assert.equal(cache.counter, cacheStart);
assert.equal(properties.counter, propertiesStart);
});
+
+ it('should skip the local memory cache when desired', () => {
+ var properties = new MockProperties();
+ var service = OAuth2.createService('test')
+ .setPropertyStore(properties);
+ var token = {
+ access_token: 'foo'
+ };
+ service.saveToken_(token);
+
+ var newToken = {
+ access_token: 'bar'
+ };
+ properties.setProperty('oauth2.test', JSON.stringify(newToken));
+
+ assert.deepEqual(service.getToken(true), newToken);
+ });
+
+ it('should load null tokens from the cache', () => {
+ var cache = new MockCache();
+ var properties = new MockProperties();
+ for (var i = 0; i < 10; ++i) {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(properties)
+ .setCache(cache);
+ service.getToken();
+ }
+ assert.equal(properties.counter, 1);
+ });
+
+ it('should load null tokens from memory', () => {
+ var cache = new MockCache();
+ var properties = new MockProperties();
+ var service = OAuth2.createService('test')
+ .setPropertyStore(properties)
+ .setCache(cache);
+
+ service.getToken();
+ var cacheStart = cache.counter;
+ var propertiesStart = properties.counter;
+ for (var i = 0; i < 10; ++i) {
+ service.getToken();
+ }
+ assert.equal(cache.counter, cacheStart);
+ assert.equal(properties.counter, propertiesStart);
+ });
+
+ it('should not fail if no properties are set', () => {
+ var service = OAuth2.createService('test');
+ service.getToken();
+ });
});
- describe('#saveToken_()', function() {
- it('should save the token to the properties and cache', function() {
+ describe('#saveToken_()', () => {
+ it('should save the token to the properties and cache', () => {
var cache = new MockCache();
var properties = new MockProperties();
var service = OAuth2.createService('test')
@@ -109,10 +218,18 @@ describe('Service', function() {
assert.deepEqual(JSON.parse(cache.get(key)), token);
assert.deepEqual(JSON.parse(properties.getProperty(key)), token);
});
+
+ it('should not fail if no properties are set', () => {
+ var service = OAuth2.createService('test');
+ var token = {
+ access_token: 'foo'
+ };
+ service.saveToken_(token);
+ });
});
- describe('#reset()', function() {
- it('should delete the token from properties and cache', function() {
+ describe('#reset()', () => {
+ it('should delete the token from properties and cache', () => {
var cache = new MockCache();
var properties = new MockProperties();
var service = OAuth2.createService('test')
@@ -131,15 +248,56 @@ describe('Service', function() {
assert.notExists(cache.get(key));
assert.notExists(properties.getProperty(key));
});
+
+ it('should delete values in storage', () => {
+ var cache = new MockCache();
+ var properties = new MockProperties();
+ var service = OAuth2.createService('test')
+ .setPropertyStore(properties)
+ .setCache(cache);
+ var storage = service.getStorage();
+ storage.setValue('foo', 'bar');
+
+ service.reset();
+
+ assert.notExists(storage.getValue('foo'));
+ });
+
+ it('should not delete values from other services', () => {
+ var cache = new MockCache();
+ var properties = new MockProperties();
+ var service = OAuth2.createService('test')
+ .setPropertyStore(properties)
+ .setCache(cache);
+ var values = {
+ 'oauth2.something': 'token',
+ 'oauth2.something.foo': 'bar',
+ 'oauth2.something.test': 'baz',
+ 'oauth2.testing': 'token',
+ 'oauth2.testing.foo': 'bar',
+ };
+ for (const [key, value] of Object.entries(values)) {
+ properties.setProperty(key, value);
+ cache.put(key, value);
+ }
+
+ service.reset();
+
+ for (const [key, value] of Object.entries(values)) {
+ assert.equal(cache.get(key), value);
+ assert.equal(properties.getProperty(key), value);
+ }
+ });
});
- describe('#hasAccess()', function() {
- it('should use the lock to prevent concurrent access', function(done) {
+ describe('#hasAccess()', () => {
+ it('should use the lock to prevent concurrent access', (done) => {
var token = {
granted_time: 100,
expires_in: 100,
refresh_token: 'bar'
};
+ var lock = new MockLock();
var properties = new MockProperties({
'oauth2.test': JSON.stringify(token)
});
@@ -149,110 +307,554 @@ describe('Service', function() {
access_token: Math.random().toString(36)
});
- var getAccessToken = function() {
- var service = OAuth2.createService('test')
- .setClientId('abc')
- .setClientSecret('def')
- .setTokenUrl('http://www.example.com/')
- .setPropertyStore(properties)
- .setLock(new MockLock());
- if (service.hasAccess()) {
- return service.getAccessToken();
- } else {
- throw new Error('No access: ' + service.getLastError());
- };
- }.future();
-
- Future.task(function() {
- var first = getAccessToken();
- var second = getAccessToken();
- Future.wait(first, second);
- return [first.get(), second.get()];
- }).resolve(function(err, accessTokens) {
- if (err) {
- done(err);
- }
- assert.equal(accessTokens[0], accessTokens[1]);
- done();
+ var service = OAuth2.createService('test')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenUrl('http://www.example.com/')
+ .setPropertyStore(properties)
+ .setLock(lock);
+ service.hasAccess();
+ assert.equal(lock.counter, 1);
+ done();
+ });
+
+ it('should not acquire a lock when the token is not expired', () => {
+ var token = {
+ granted_time: (new Date()).getTime(),
+ expires_in: 1000,
+ access_token: 'foo',
+ refresh_token: 'bar'
+ };
+ var lock = new MockLock();
+ var properties = new MockProperties({
+ 'oauth2.test': JSON.stringify(token)
});
+ var service = OAuth2.createService('test')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenUrl('http://www.example.com/')
+ .setPropertyStore(properties)
+ .setLock(lock);
+ service.hasAccess();
+ assert.equal(lock.counter, 0);
});
- });
- describe('#refresh()', function() {
- /*
- A race condition can occur when two executions attempt to refresh the
- token at the same time. Some OAuth implementations only allow one
- valid access token at a time, so we need to ensure that the last access
- token granted is the one that is persisted. To replicate this, we have the
- first exeuction wait longer for it's response to return through the
- "network" and have the second execution get it's response back sooner.
- */
- it('should use the lock to prevent race conditions', function(done) {
+ it('should not acquire a lock when there is no refresh token', () => {
var token = {
granted_time: 100,
expires_in: 100,
+ access_token: 'foo',
+ };
+ var lock = new MockLock();
+ var properties = new MockProperties({
+ 'oauth2.test': JSON.stringify(token)
+ });
+ var service = OAuth2.createService('test')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenUrl('http://www.example.com/')
+ .setPropertyStore(properties)
+ .setLock(lock);
+ service.hasAccess();
+ assert.equal(lock.counter, 0);
+ });
+ });
+
+ describe('#refresh()', () => {
+ it('should use the lock to prevent race conditions', (done) => {
+ var token = {
+ granted_time: 100,
+ expires_in: 0,
refresh_token: 'bar'
};
+ var lock = new MockLock();
var properties = new MockProperties({
'oauth2.test': JSON.stringify(token)
});
- var count = 0;
- mocks.UrlFetchApp.resultFunction = function() {
+ mocks.UrlFetchApp.resultFunction = () => JSON.stringify({
+ access_token: Math.random().toString(36)
+ });
+ OAuth2.createService('test')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenUrl('http://www.example.com/')
+ .setPropertyStore(properties)
+ .setLock(lock)
+ .refresh();
+ assert.equal(lock.counter, 1);
+ done();
+ });
+
+ it('should retain refresh expiry', () => {
+ const NOW_SECONDS = OAuth2.getTimeInSeconds_(new Date());
+ const ONE_HOUR_AGO_SECONDS = NOW_SECONDS - 360;
+ var token = {
+ granted_time: ONE_HOUR_AGO_SECONDS,
+ expires_in: 100,
+ refresh_token: 'bar',
+ refresh_token_expires_in: 720
+ };
+ var properties = new MockProperties({
+ 'oauth2.test': JSON.stringify(token)
+ });
+
+ mocks.UrlFetchApp.resultFunction = () => {
return JSON.stringify({
- access_token: 'token' + count++
+ access_token: 'token'
});
};
- var delayGenerator = function*() {
- yield 100;
- yield 10;
- }();
- mocks.UrlFetchApp.delayFunction = function() {
- return delayGenerator.next().value;
+
+ OAuth2.createService('test')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenUrl('http://www.example.com/')
+ .setPropertyStore(properties)
+ .setLock(new MockLock())
+ .refresh();
+
+ var storedToken = JSON.parse(properties.getProperty('oauth2.test'));
+ assert.equal(storedToken.access_token, 'token');
+ assert.equal(storedToken.refresh_token, 'bar');
+ assert.equal(storedToken.refreshTokenExpiresAt, NOW_SECONDS + 360);
+ });
+ });
+
+ describe('#exchangeGrant_()', () => {
+ var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;
+
+ it('should not set auth header if not client_credentials', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
+ done();
};
+ var service = OAuth2.createService('test')
+ .setGrantType('fake')
+ .setTokenUrl('http://www.example.com/');
+ service.exchangeGrant_();
+ });
- var refreshToken = function() {
- OAuth2.createService('test')
- .setClientId('abc')
- .setClientSecret('def')
- .setTokenUrl('http://www.example.com/')
- .setPropertyStore(properties)
- .setLock(new MockLock())
- .refresh();
- }.future();
-
- Future.task(function() {
- var first = refreshToken();
- var second = refreshToken();
- Future.wait(first, second);
- return [first.get(), second.get()];
- }).resolve(function(err) {
- if (err) {
- done(err);
- }
- var storedToken = JSON.parse(properties.getProperty('oauth2.test'));
- assert.equal(storedToken.access_token, 'token1');
+ it('should not set auth header if the client ID is not set', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
+ done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/');
+ service.exchangeGrant_();
+ });
+
+ it('should not set auth header if the client secret is not set', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.isUndefined(toLowerCaseKeys_(urlOptions.headers).authorization);
done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/')
+ .setClientId('abc');
+ service.exchangeGrant_();
+ });
+
+ it('should not set auth header if it is already set', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
+ 'something');
+ done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setTokenHeaders({
+ authorization: 'something'
+ });
+ service.exchangeGrant_();
+ });
+
+ it('should set the auth header for the client_credentials grant type, if ' +
+ 'the client ID and client secret are set and the authorization header' +
+ 'is not already set', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.equal(toLowerCaseKeys_(urlOptions.headers).authorization,
+ 'Basic YWJjOmRlZg==');
+ done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/')
+ .setClientId('abc')
+ .setClientSecret('def');
+ service.exchangeGrant_();
+ });
+ });
+
+ describe('#generateCodeVerifier()', () => {
+ it('should not include code challenge unless requested', () => {
+ var service = OAuth2.createService('test')
+ .setAuthorizationBaseUrl('http://www.example.com/')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setCallbackFunction('authCallback');
+ var authorizationUrl = service.getAuthorizationUrl({});
+ assert.notInclude(authorizationUrl, 'code_challenge');
+ assert.notInclude(authorizationUrl, 'code_challenge_method');
+ });
+
+ it('should use generated challenge string', () => {
+ var service = OAuth2.createService('test')
+ .setAuthorizationBaseUrl('http://www.example.com/')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setCallbackFunction('authCallback')
+ .generateCodeVerifier();
+ var authorizationUrl = service.getAuthorizationUrl({});
+ assert.include(authorizationUrl, 'code_challenge');
+ assert.include(authorizationUrl, 'code_challenge_method=S256');
+ });
+ });
+
+ describe('#getAuthorizationUrl()', () => {
+ it('should add additional parameters to the state token', () => {
+ var service = OAuth2.createService('test')
+ .setAuthorizationBaseUrl('http://www.example.com/')
+ .setClientId('abc')
+ .setClientSecret('def')
+ .setCallbackFunction('authCallback');
+ var authorizationUrl = service.getAuthorizationUrl({
+ foo: 'bar'
+ });
+
+ // Extract the state token from the URL and parse it. For example, the
+ // URL http://www.example.com?state=%7B%22a%22%3A1%7D would produce
+ // {a: 1}.
+ var querystring = authorizationUrl.split('?')[1];
+ var params = querystring.split('&').reduce((result, pair) => {
+ var parts = pair.split('=').map(decodeURIComponent);
+ result[parts[0]] = parts[1];
+ return result;
+ }, {});
+ var state = JSON.parse(params.state);
+
+ assert.equal(state.arguments.foo, 'bar');
+ });
+ });
+
+ describe('#ensureExpiresAtSet_()', () => {
+ const NOW_SECONDS = OAuth2.getTimeInSeconds_(new Date());
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+
+ it('should set expires at', () => {
+ const token = {
+ granted_time: NOW_SECONDS,
+ expires_in_sec: 100
+ };
+ service.ensureExpiresAtSet_(token);
+ assert.include(token, {
+ expiresAt: NOW_SECONDS + 100
+ });
+ });
+
+ it('should set refresh expires at', () => {
+ const token = {
+ granted_time: NOW_SECONDS,
+ refresh_token_expires_in: 200
+ };
+ service.ensureExpiresAtSet_(token);
+ assert.include(token, {
+ refreshTokenExpiresAt: NOW_SECONDS + 200
});
});
});
+
+ describe('#canRefresh_()', () => {
+ const NOW_SECONDS = OAuth2.getTimeInSeconds_(new Date());
+ const ONE_HOUR_AGO_SECONDS = NOW_SECONDS - 360;
+ const ONE_HOUR_LATER_SECONDS = NOW_SECONDS + 360;
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+
+ it('should return false if no refresh token', () => {
+ const token = {};
+ assert.isFalse(service.canRefresh_(token));
+ });
+
+ it('should return true if there is a refresh token', () => {
+ const token = {
+ refresh_token: 'bar',
+ };
+ assert.isTrue(service.canRefresh_(token));
+ });
+
+ it('should return true if it is not expired', () => {
+ const token = {
+ refresh_token: 'bar',
+ expiresAt: ONE_HOUR_LATER_SECONDS,
+ refreshTokenExpiresAt: ONE_HOUR_LATER_SECONDS
+ };
+ console.log('test');
+ assert.isTrue(service.canRefresh_(token));
+ });
+
+ it('should return false if it is expired', () => {
+ const token = {
+ refresh_token: 'bar',
+ expiresAt: ONE_HOUR_LATER_SECONDS,
+ refreshTokenExpiresAt: ONE_HOUR_AGO_SECONDS
+ };
+ assert.isFalse(service.canRefresh_(token));
+ });
+ });
+
+ describe('#isExpired_()', () => {
+ const NOW_SECONDS = OAuth2.getTimeInSeconds_(new Date());
+ const ONE_HOUR_AGO_SECONDS = NOW_SECONDS - 360;
+
+
+ it('should return false if there is no expiration time', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {};
+
+ assert.isFalse(service.isExpired_(token));
+ });
+
+ it('should return false if before the time in expires_in', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 360, // One hour.
+ granted_time: NOW_SECONDS,
+ };
+
+ assert.isFalse(service.isExpired_(token));
+ });
+
+ it('should return true if past the time in "expires_in"', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 60, // One minute.
+ granted_time: ONE_HOUR_AGO_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return false if before the time in "expires"', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 360, // One hour.
+ granted_time: NOW_SECONDS,
+ };
+
+ assert.isFalse(service.isExpired_(token));
+ });
+
+ it('should return true if past the time in "expires"', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 60, // One minute.
+ granted_time: ONE_HOUR_AGO_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return false if before the time in "expires_in_sec"', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 360, // One hour.
+ granted_time: NOW_SECONDS,
+ };
+
+ assert.isFalse(service.isExpired_(token));
+ });
+
+ it('should return true if past the time in "expires_in_sec"', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 60, // One minute.
+ granted_time: ONE_HOUR_AGO_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return true if within the buffer', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var token = {
+ expires_in: 30, // 30 seconds.
+ granted_time: NOW_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return true if past the JWT expiration', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var idToken = OAuth2.encodeJwt_({
+ exp: NOW_SECONDS - 60, // One minute ago.
+ }, 'key');
+ var token = {
+ id_token: idToken,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return false if the JWT is expired but the token is not', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var idToken = OAuth2.encodeJwt_({
+ exp: NOW_SECONDS - 60, // One minute ago.
+ }, 'key');
+ var token = {
+ id_token: idToken,
+ expires_in: 360, // One hour.
+ granted_time: NOW_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+
+ it('should return false if the token expired but the JWT is not', () => {
+ var service = OAuth2.createService('test')
+ .setPropertyStore(new MockProperties())
+ .setCache(new MockCache());
+ var idToken = OAuth2.encodeJwt_({
+ exp: NOW_SECONDS + 360, // One hour from now.
+ }, 'key');
+ var token = {
+ id_token: idToken,
+ expires_in: 60, // One minute.
+ granted_time: ONE_HOUR_AGO_SECONDS,
+ };
+
+ assert.isTrue(service.isExpired_(token));
+ });
+ });
});
-describe('Utilities', function() {
- describe('#extend_()', function() {
+describe('Utilities', () => {
+ describe('#extend_()', () => {
var extend_ = OAuth2.extend_;
var baseObj = {foo: [3]}; // An object with a non-primitive key-value
- it('should extend (left) an object', function() {
+ it('should extend (left) an object', () => {
var o = extend_(baseObj, {bar: 2});
assert.deepEqual(o, {foo: [3], bar: 2});
});
- it('should extend (right) an object', function() {
+ it('should extend (right) an object', () => {
var o = extend_({bar: 2}, baseObj);
assert.deepEqual(o, {foo: [3], bar: 2});
});
- it('should extend (merge) an object', function() {
+ it('should extend (merge) an object', () => {
var o = extend_(baseObj, {foo: [100], bar: 2, baz: {}});
assert.deepEqual(o, {foo: [100], bar: 2, baz: {}});
});
});
+
+ describe('#toLowerCaseKeys_()', () => {
+ var toLowerCaseKeys_ = OAuth2.toLowerCaseKeys_;
+
+ it('should contain only lower-case keys', () => {
+ var data = {
+ 'a': true,
+ 'A': true,
+ 'B': true,
+ 'Cc': true,
+ 'D2': true,
+ 'E!@#': true
+ };
+ var lowerCaseData = toLowerCaseKeys_(data);
+ assert.deepEqual(lowerCaseData, {
+ 'a': true,
+ 'b': true,
+ 'cc': true,
+ 'd2': true,
+ 'e!@#': true
+ });
+ });
+
+ it('should handle null, undefined, and empty objects', () => {
+ assert.isNull(toLowerCaseKeys_(null));
+ assert.isUndefined(toLowerCaseKeys_(undefined));
+ assert.isEmpty(toLowerCaseKeys_({}));
+ });
+ });
+
+ describe('#encodeJwt_()', () => {
+ var encodeJwt_ = OAuth2.encodeJwt_;
+
+ it('should encode correctly', () => {
+ var payload = {
+ 'foo': 'bar'
+ };
+
+ var jwt = encodeJwt_(payload, 'key');
+ var parts = jwt.split('.');
+
+ // Expexted values from jwt.io.
+ assert.equal(parts[0], 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9');
+ assert.equal(parts[1], 'eyJmb28iOiJiYXIifQ');
+ });
+ });
+
+ describe('#decodeJwt_()', () => {
+ var decodeJwt_ = OAuth2.decodeJwt_;
+
+ it('should decode correctly', () => {
+ var jwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.sig';
+
+ var payload = decodeJwt_(jwt);
+
+ assert.deepEqual(payload, {'foo': 'bar'});
+ });
+ });
+
+ describe('#setTokenMethod()', () => {
+ it('should defautl to POST', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.equal(urlOptions.method, 'post');
+ done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/');
+ service.exchangeGrant_();
+ });
+
+ it('should change the HTTP method used', (done) => {
+ mocks.UrlFetchApp.resultFunction = (url, urlOptions) => {
+ assert.equal(urlOptions.method, 'put');
+ done();
+ };
+ var service = OAuth2.createService('test')
+ .setGrantType('client_credentials')
+ .setTokenUrl('http://www.example.com/')
+ .setTokenMethod('put');
+ service.exchangeGrant_();
+ });
+ });
});