/**
* Shortcut function to convert from decimal to hexadecimal.
*
* @param {string} dec The message to convert.
*
* @return {string} The hexadecimal.
*/
function dec2hex(dec) {
return ("0" + dec.toString(16)).substr(-2);
}
/**
* Get a random string
* @param {string} size (Optional)
* @return {string}
*/
function generateCodeVerifier(size = 128) {
var array = new Uint32Array(size / 2);
crypto.getRandomValues(array);
return Array.from(array, dec2hex).join("");
}
/**
* Shortcut function to the hasher's object interface.
*
* @param {string} plain The message to hash.
*
* @return {ArrayBuffer} The hash.
*/
function sha256(plain) {
// returns promise ArrayBuffer
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return crypto.subtle.digest("SHA-256", data);
}
/**
* Base64 encoding strategy.
*
* @param {string} plain The message to encode.
*
* @return {string}
*/
function base64urlencode(plain) {
var str = "";
var bytes = new Uint8Array(plain);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* Generate the code challenge
* @param {string} codeVerifier conde verifier to generate challenge
* @return Promise<string>
*/
async function generateCodeChallenge(codeVerifier) {
var hashed = await sha256(codeVerifier);
var base64encoded = base64urlencode(hashed);
return base64encoded;
}
/**
* @ignore
*/
class PKCE {
state = ""
codeVerifier = ""
corsRequestOptions = {}
/**
* Initialize the instance with configuration
* @param {IConfig} config
*/
constructor(config) {
this.config = config
}
/**
* Allow the user to enable cross domain cors requests
* @param enable turn the cross domain request options on.
* @return ICorsOptions
*/
enableCorsCredentials(enable) {
this.corsRequestOptions = enable
? {
credentials: "include",
mode: "cors"
}
: {}
return this.corsRequestOptions
}
/**
* Generate the authorize url
* @param {object} additionalParams include additional parameters in the query
* @return Promise<string>
*/
async authorizeUrl(additionalParams = {}) {
const codeChallenge = await this.pkceChallengeFromVerifier()
const queryString = new URLSearchParams(
Object.assign(
{
response_type: "code",
client_id: this.config.client_id,
state: this.getState(additionalParams.state || null),
scope: this.config.requested_scopes,
redirect_uri: this.config.redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: "S256"
},
additionalParams
)
).toString()
return `${this.config.authorization_endpoint}?${queryString}`
}
/**
* Given the return url, get a token from the oauth server
* @param url current urlwith params from server
* @param {object} additionalParams include additional parameters in the request body
* @return {Promise<ITokenResponse>}
*/
exchangeForAccessToken(url, additionalParams = {}) {
return this.parseAuthResponseUrl(url).then(q => {
return fetch(this.config.token_endpoint, {
method: "POST",
body: new URLSearchParams(
Object.assign(
{
grant_type: "authorization_code",
code: q.code,
client_id: this.config.client_id,
redirect_uri: this.config.redirect_uri,
code_verifier: this.getCodeVerifier()
},
additionalParams
)
),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
...this.corsRequestOptions
}).then((response) => {
if (response.ok) {
return response.json();
}
console.log(response.json());
throw new Error('Something went during access token exchange');
})
})
}
/**
* Given a refresh token, return a new token from the oauth server
* @param refreshTokens current refresh token from server
* @return {Promise<ITokenResponse>}
*/
refreshAccessToken(refreshToken) {
return fetch(this.config.token_endpoint, {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: this.config.client_id,
refresh_token: refreshToken
}),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
}
}).then((response) => {
if (response.ok) {
return response.json();
}
console.log(response.json());
throw new Error('Something went during access token refresh');
})
}
/**
* Get the current codeVerifier or generate a new one
* @return {string}
*/
getCodeVerifier() {
if (this.codeVerifier === "") {
this.codeVerifier = this.randomStringFromStorage("pkce_code_verifier")
}
return this.codeVerifier
}
/**
* Get the current state or generate a new one
* @return {string}
*/
getState(explicit = null) {
const stateKey = "pkce_state"
if (explicit !== null) {
this.getStore().setItem(stateKey, explicit)
}
if (this.state === "") {
this.state = this.randomStringFromStorage(stateKey)
}
return this.state
}
/**
* Get the query params as json from a auth response url
* @param {string} url a url expected to have AuthResponse params
* @return {Promise<IAuthResponse>}
*/
parseAuthResponseUrl(url) {
let params;
if (url.includes('#')){
const [hash, query] = url.split('#')[1].split('?');
params = new URLSearchParams(hash);
} else {
params = new URL(url).searchParams;
}
return this.validateAuthResponse({
error: params.get("error"),
query: params.get("query"),
state: params.get("state"),
code: params.get("code")
})
}
/**
* Generate a code challenge
* @return {Promise<string>}
*/
async pkceChallengeFromVerifier() {
let v = this.getCodeVerifier();
var hashed = await sha256(v);
var base64encoded = base64urlencode(hashed);
return base64encoded;
}
/**
* Get a random string from storage or store a new one and return it's value
* @param {string} key
* @return {string}
*/
randomStringFromStorage(key) {
const fromStorage = this.getStore().getItem(key)
if (fromStorage === null) {
this.getStore().setItem(key, generateCodeVerifier())
}
return this.getStore().getItem(key) || ""
}
/**
* Validates params from auth response
* @param {AuthResponse} queryParams
* @return {Promise<IAuthResponse>}
*/
validateAuthResponse(queryParams) {
return new Promise((resolve, reject) => {
if (queryParams.error) {
return reject({ error: queryParams.error })
}
if (queryParams.state !== this.getState()) {
return reject({ error: "Invalid State" })
}
return resolve(queryParams)
})
}
/**
* Get the storage (sessionStorage / localStorage) to use, defaults to sessionStorage
* @return {Storage}
*/
getStore() {
return this.config?.storage || sessionStorage
}
}
export { PKCE, generateCodeVerifier, generateCodeChallenge }