Skip to content

Instantly share code, notes, and snippets.

@mxmnci
Last active June 11, 2023 18:23
Show Gist options
  • Save mxmnci/23690aa344e5c84940546dd5b983ae97 to your computer and use it in GitHub Desktop.
Save mxmnci/23690aa344e5c84940546dd5b983ae97 to your computer and use it in GitHub Desktop.
A preflight script that automatically handles OAuth 2.0 in Apollo Sandbox for Microsoft Active Directory
// 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