Created
April 4, 2020 08:00
-
-
Save robinvdvleuten/0909d4c7a0f0eb84bb6f84143ed305eb to your computer and use it in GitHub Desktop.
React + Authorization Grant
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
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