Skip to content

Instantly share code, notes, and snippets.

@robinvdvleuten
Created April 4, 2020 08:00
Show Gist options
  • Save robinvdvleuten/0909d4c7a0f0eb84bb6f84143ed305eb to your computer and use it in GitHub Desktop.
Save robinvdvleuten/0909d4c7a0f0eb84bb6f84143ed305eb to your computer and use it in GitHub Desktop.
React + Authorization Grant
import React, { useContext, useMemo, useState } from 'react'
class OAuthError extends Error {
constructor(error, description, uri) {
super(error)
this._description = description
this._uri = uri
}
get description() {
return this._description
}
get uri() {
return this._uri
}
}
function exchangeCodeForAccessToken(
endpoint,
clientId,
redirectUri,
code,
codeVerifier
) {
// Exchange the authorization code for an access token
return fetch(endpoint, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded'
}),
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier
})
})
.then(response =>
Promise.all([
/json/.test(response.headers.get('Content-Type'))
? response.json()
: null,
response
])
)
.then(([payload, response]) => {
if (!response.ok) {
const error = new OAuthError(
payload.error,
payload.error_description,
payload.error_uri
)
error.response = response
throw error
}
return {
accessToken: payload.access_token,
tokenType: payload.token_type,
expiresIn: payload.expires_in,
scope: payload.scope
}
})
}
function retrieveCodeThroughIframe(url) {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
const timeoutId = setTimeout(() => {
reject(new Error('Timeout.'))
window.document.body.removeChild(iframe)
}, 60000)
function responseHandler(event) {
if (
event.origin !== url.origin ||
event.data.type !== 'authorization_response'
) {
return
}
event.source.close()
clearTimeout(timeoutId)
window.removeEventListener('message', responseHandler, false)
window.document.body.removeChild(iframe)
const response = event.data.response
if (response.error) {
const error = new OAuthError(
response.error,
response.error_description,
response.error_uri
)
error.response = response
return reject(error)
}
resolve(response)
}
window.addEventListener('message', responseHandler)
window.document.body.appendChild(iframe)
iframe.setAttribute('src', url)
})
}
function crypto() {
return window.crypto || window.msCrypto
}
/**
* Generate a secure random string using the browser crypto functions.
*/
function generateRandomString() {
let array = new Uint32Array(28)
crypto().getRandomValues(array)
return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('')
}
/**
* Calculate the SHA256 hash of the input text.
* Returns a promise that resolves to an ArrayBuffer
*/
function sha256(plain) {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return crypto().subtle.digest('SHA-256', data)
}
/**
* Base64-urlencodes the input string.
*/
function base64urlencode(str) {
// Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts.
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* Return the base64-urlencoded sha256 hash for the PKCE challenge
*/
function pkceChallengeFromVerifier(verifier) {
return sha256(verifier).then(base64urlencode)
}
export const AuthContext = React.createContext()
function getAndDeleteItem(key) {
const value = window.localStorage.getItem(key)
window.localStorage.removeItem(key)
return value
}
const STORAGE_KEY_STATE = '_pkce_state'
const STORAGE_KEY_CODE_VERIFIER = '_pkce_code_verifier'
export default function createAuth(
clientId,
audience,
authorizeEndpoint,
tokenEndpoint,
redirectUri,
scope
) {
function generateRedirectUrl(extra) {
// Create a random "state" value
const state = generateRandomString()
// Create a new PKCE code_verifier (the plaintext random secret)
const codeVerifier = generateRandomString()
// Hash and base64-urlencode the secret to use as the challenge
return pkceChallengeFromVerifier(codeVerifier)
.then(
codeChallenge =>
new URLSearchParams({
client_id: clientId,
audience,
response_type: 'code',
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope,
state,
...extra
})
)
.then(params => {
const url = new URL(authorizeEndpoint)
url.search = params
return [url, state, codeVerifier]
})
}
function callback() {
if (!window.location.search) {
return Promise.resolve(null)
}
const query = new URLSearchParams(window.location.search)
const state = getAndDeleteItem(STORAGE_KEY_STATE)
const codeVerifier = getAndDeleteItem(STORAGE_KEY_CODE_VERIFIER)
// Replace the history entry to remove the auth code from the browser address bar
window.history.replaceState({}, null, window.location.pathname)
// Verify state matches what we set at the beginning
if (!query.has('state') || query.get('state') !== state) {
return Promise.reject('Invalid state.')
}
if (query.has('error')) {
return Promise.reject(
new OAuthError(
query.get('error'),
query.get('error_description'),
query.get('error_uri')
)
)
}
if (!query.has('code')) {
return Promise.resolve(null)
}
return exchangeCodeForAccessToken(
tokenEndpoint,
clientId,
redirectUri,
query.get('code'),
codeVerifier
)
}
function redirect() {
return generateRedirectUrl().then(([url, state, codeVerifier]) => {
window.localStorage.setItem(STORAGE_KEY_STATE, state)
window.localStorage.setItem(STORAGE_KEY_CODE_VERIFIER, codeVerifier)
return url
})
}
function refresh() {
return generateRedirectUrl({
response_mode: 'web_message',
prompt: 'none'
}).then(([url, state, codeVerifier]) => {
return retrieveCodeThroughIframe(url).then(response => {
if (response.state !== state) {
throw new Error('Invalid state.')
}
return exchangeCodeForAccessToken(
tokenEndpoint,
clientId,
redirectUri,
response.code,
codeVerifier
)
})
})
}
return {
callback,
redirect,
refresh
}
}
const SUSPENDED_PROMISES = { callback: null, redirect: null, refresh: null }
export const AuthProvider = ({
children,
clientId,
audience,
authorizeEndpoint,
tokenEndpoint,
redirectUri,
scope,
useSuspense = true
}) => {
const [state, setState] = useState({ error: null })
const auth = useMemo(
() =>
createAuth(
clientId,
audience,
authorizeEndpoint,
tokenEndpoint,
redirectUri,
scope
),
[clientId, audience, authorizeEndpoint, tokenEndpoint, redirectUri, scope]
)
function suspend(key, promise) {
if (SUSPENDED_PROMISES[key]) {
if (SUSPENDED_PROMISES[key].hasOwnProperty('error')) {
throw SUSPENDED_PROMISES[key].error
}
if (SUSPENDED_PROMISES[key].hasOwnProperty('result')) {
return SUSPENDED_PROMISES[key].result
}
throw SUSPENDED_PROMISES[key]
}
const suspendedPromise = promise()
if (useSuspense) {
throw (SUSPENDED_PROMISES[key] = suspendedPromise).then(
result => {
SUSPENDED_PROMISES[key].result = result
},
error => {
SUSPENDED_PROMISES[key].error = error
}
)
}
return suspendedPromise
}
function handleCallback() {
return suspend('callback', () => {
return auth.callback().then(
result => {
setState({ ...result, error: null })
return result
},
error => {
setState({ error })
throw error
}
)
})
}
function handleRedirect() {
return suspend(
'redirect',
() =>
new Promise((resolve, reject) => {
// We use a "never resolving" promise to prevent flashes with suspense.
auth.redirect().then(url => void window.open(url, '_self'), reject)
})
)
}
function handleRefresh() {
return suspend('refresh', () =>
auth.refresh().then(
result => {
setState({ ...result, error: null })
return result
},
error => {
setState({ error })
throw error
}
)
)
}
const ctx = {
...state,
useSuspense,
callback: handleCallback,
redirect: handleRedirect,
refresh: handleRefresh
}
return <AuthContext.Provider value={ctx}>{children}</AuthContext.Provider>
}
export const useAuth = (refreshWhenSuspense = true) => {
const ctx = useContext(AuthContext)
if (ctx.useSuspense && refreshWhenSuspense && !ctx.accessToken) {
ctx.refresh()
}
return ctx
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment