Last active
June 11, 2023 18:23
A preflight script that automatically handles OAuth 2.0 in Apollo Sandbox for Microsoft Active Directory
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Define constants OAuth server endpoints | |
const ENDPOINTS = { | |
authorize: | |
'https://login.microsoftonline.com/<insert-tenant-name-here>/oauth2/v2.0/authorize', | |
token: | |
'https://login.microsoftonline.com/<insert-tenant-name-here>/oauth2/v2.0/token', | |
} | |
// OAuth client details and other configuration for the OAuth server | |
const config = { | |
// The client_id of your OAuth client registered in Microsoft Azure AD | |
client_id: '<insert-client-id-here>', | |
// The tenant_id of your Azure AD tenant | |
tenant_id: '<insert-tenant-id-here>', | |
// A string value to maintain state between the request and callback | |
state: '<insert-unique-state-string-here>', | |
// The scope value indicating the permissions the app requires | |
scope: '<insert-scope-here>', | |
// The response_type value as per OAuth 2.0 specification | |
response_type: 'code', | |
// The method used to encode the code_challenge | |
code_challenge_method: 'S256', | |
// The code_challenge created by our Node.js script | |
code_challenge: explorer.environment.get('code_challenge'), | |
// The code_verifier created by our Node.js script | |
code_verifier: explorer.environment.get('code_verifier'), | |
// The URI in which the user is redirected back to after authentication | |
redirect_uri: 'https://studio.apollographql.com/explorer-oauth2', | |
} | |
// Helper function to validate configuration values | |
function validateConfig(config) { | |
Object.keys(config).forEach((key) => { | |
if (!config[key]) throw new Error(`Missing config value for ${key}`) | |
}) | |
} | |
/** | |
* Manages the lifecycle of tokens for the application, handling the retrieval, | |
* refresh, and validation of both access tokens and refresh tokens | |
*/ | |
class TokenManager { | |
constructor() { | |
this.token = explorer.environment.get('token') | |
this.refreshToken = explorer.environment.get('refresh_token') | |
} | |
/** | |
* Get the current access token. If the current token is expired, attempt to refresh it | |
* If the refresh token is also expired, retrieve new tokens | |
* @returns {Promise<string>} The current access token | |
*/ | |
async getAccessToken() { | |
if (this.token && !this.isTokenExpired() && this.isValidTokenHeader()) { | |
return this.token | |
} | |
// If the refresh token exists, try refreshing the access token | |
if (this.refreshToken) { | |
try { | |
this.token = await this.refreshAccessToken() | |
explorer.environment.set('token', this.token) | |
return this.token | |
} catch (error) { | |
console.error('Failed to refresh access token:', error) | |
} | |
} | |
// If we got here, we have no tokens or refresh failed, so get new ones | |
const tokens = await this.retrieveNewTokens() | |
this.token = tokens.access_token | |
this.refreshToken = tokens.refresh_token | |
explorer.environment.set('token', this.token) | |
explorer.environment.set('refresh_token', this.refreshToken) | |
return this.token | |
} | |
/** | |
* Retrieve new access and refresh tokens from the OAuth server | |
* @returns {Promise<Object>} An object containing the new access and refresh tokens | |
*/ | |
async retrieveNewTokens() { | |
const { | |
client_id, | |
tenant_id, | |
state, | |
scope, | |
response_type, | |
code_challenge_method, | |
code_challenge, | |
code_verifier, | |
redirect_uri, | |
} = config | |
const { code } = await explorer.oauth2Request(ENDPOINTS.authorize, { | |
client_id, | |
state, | |
scope, | |
response_type, | |
code_challenge_method, | |
code_challenge, | |
}) | |
const tokenResponse = await explorer.fetch(ENDPOINTS.token, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/x-www-form-urlencoded', | |
}, | |
body: this.constructRequestBody({ | |
client_id, | |
tenant_id, | |
code, | |
code_verifier, | |
grant_type: 'authorization_code', | |
redirect_uri, | |
}), | |
}) | |
const { access_token, refresh_token } = JSON.parse(tokenResponse.body) | |
return { access_token, refresh_token } | |
} | |
/** | |
* Refresh the current access token using the current refresh token | |
* @returns {Promise<string>} The new access token | |
* @throws {Error} If the refresh token is expired | |
*/ | |
async refreshAccessToken() { | |
const { client_id } = config | |
const tokenResponse = await explorer.fetch(ENDPOINTS.token, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/x-www-form-urlencoded', | |
}, | |
body: this.constructRequestBody({ | |
client_id, | |
grant_type: 'refresh_token', | |
refresh_token: this.refreshToken, | |
}), | |
}) | |
const { access_token, error } = JSON.parse(tokenResponse.body) | |
// If an error occurs during refresh, it is likely that the refresh token has expired | |
if (error) { | |
throw new Error( | |
'Unable to refresh access token. Refresh token may be expired.' | |
) | |
} | |
return access_token | |
} | |
/** | |
* Check if the current access token is expired | |
* @returns {boolean} True if the token is expired, false otherwise | |
*/ | |
isTokenExpired() { | |
const decodedAccessToken = this.parseJwt(this.token) | |
return decodedAccessToken.exp < Math.floor(Date.now() / 1000) | |
} | |
/** | |
* Validate the header of the current JWT token | |
* This is used to ensure that the token header isn't malformed, which could | |
* happen if the token was manually set in the environment variables | |
* @returns {boolean} True if the token header is valid, false otherwise | |
*/ | |
isValidTokenHeader() { | |
try { | |
const [headerBase64Url] = this.token.split('.') | |
const base64 = headerBase64Url.replace(/-/g, '+').replace(/_/g, '/') | |
const jsonPayload = decodeURIComponent( | |
atob(base64) | |
.split('') | |
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) | |
.join('') | |
) | |
const header = JSON.parse(jsonPayload) | |
if (header.typ !== 'JWT') { | |
return false | |
} | |
if (['HS256', 'RS256'].indexOf(header.alg) === -1) { | |
return false | |
} | |
return true | |
} catch (e) { | |
return false | |
} | |
} | |
/** | |
* Parse a JWT token and return the decoded payload | |
* @param {string} token The JWT token to parse | |
* @returns {Object} The decoded payload of the JWT token | |
*/ | |
parseJwt(token) { | |
var base64Url = token.split('.')[1] | |
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') | |
var jsonPayload = decodeURIComponent( | |
atob(base64) | |
.split('') | |
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) | |
.join('') | |
) | |
return JSON.parse(jsonPayload) | |
} | |
/** | |
* Convert a data object to a URL-encoded string | |
* @param {Object} data The data to send in the request body | |
* @returns {string} The data formatted as a URL-encoded string | |
*/ | |
constructRequestBody(data) { | |
return Object.keys(data) | |
.map((key) => `${key}=${encodeURIComponent(data[key])}`) | |
.join('&') | |
} | |
} | |
// Validate the configuration | |
validateConfig(config) | |
// Initialize the token manager and retrieve the access token | |
const tokenManager = new TokenManager(config) | |
await tokenManager.getAccessToken() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment