@kinde-oss/kinde-auth-pkce-js
Version:
Kinde PKCE authentication for SPAs
678 lines (656 loc) • 23.5 kB
JavaScript
// Generated by genversion.
const version = '4.3.0';
const SESSION_PREFIX = 'pkce-code-verifier';
var flagDataTypeMap;
(function (flagDataTypeMap) {
flagDataTypeMap["s"] = "string";
flagDataTypeMap["i"] = "integer";
flagDataTypeMap["b"] = "boolean";
})(flagDataTypeMap || (flagDataTypeMap = {}));
var storageMap;
(function (storageMap) {
storageMap["token_bundle"] = "kinde_token";
storageMap["access_token"] = "kinde_access_token";
storageMap["id_token"] = "kinde_id_token";
storageMap["user"] = "user";
storageMap["refresh_token"] = "kinde_refresh_token";
})(storageMap || (storageMap = {}));
class InvalidTokenError extends Error {
}
InvalidTokenError.prototype.name = "InvalidTokenError";
function b64DecodeUnicode(str) {
return decodeURIComponent(atob(str).replace(/(.)/g, (m, p) => {
let code = p.charCodeAt(0).toString(16).toUpperCase();
if (code.length < 2) {
code = "0" + code;
}
return "%" + code;
}));
}
function base64UrlDecode(str) {
let output = str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw new Error("base64 string is not of the correct length");
}
try {
return b64DecodeUnicode(output);
}
catch (err) {
return atob(output);
}
}
function jwtDecode(token, options) {
if (typeof token !== "string") {
throw new InvalidTokenError("Invalid token specified: must be a string");
}
options || (options = {});
const pos = options.header === true ? 0 : 1;
const part = token.split(".")[pos];
if (typeof part !== "string") {
throw new InvalidTokenError(`Invalid token specified: missing part #${pos + 1}`);
}
let decoded;
try {
decoded = base64UrlDecode(part);
}
catch (e) {
throw new InvalidTokenError(`Invalid token specified: invalid base64 for part #${pos + 1} (${e.message})`);
}
try {
return JSON.parse(decoded);
}
catch (e) {
throw new InvalidTokenError(`Invalid token specified: invalid json for part #${pos + 1} (${e.message})`);
}
}
const hasCookie = (name) => document.cookie
.split('; ')
.find((row) => row.split('=')[0] === name)
?.split('=')[1];
// 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 =)
const numberArray = Array.from(new Uint8Array(str));
return btoa(String.fromCharCode.apply(null, numberArray))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 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 window.crypto.subtle.digest('SHA-256', data);
}
// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
const hashed = await sha256(v);
return base64UrlEncode(hashed);
}
function randomString() {
const array = new Uint32Array(28);
window.crypto.getRandomValues(array);
return Array.from(array, (dec) => ('0' + dec.toString(16)).substr(-2)).join('');
}
// Early expiraton in seconds
const EARLY_EXPIRATION = 30;
const isJWTActive = (jwtToken) => {
const unixTime = Math.floor(Date.now() / 1000);
return jwtToken.exp - EARLY_EXPIRATION > unixTime;
};
const setupChallenge = async (authorizationEndpoint, appState) => {
const state = randomString();
const code_verifier = randomString(); // the secret
// Hash and base64-urlencode the secret to use as the challenge
const code_challenge = await pkceChallengeFromVerifier(code_verifier);
sessionStorage.setItem(`${SESSION_PREFIX}-${state}`, JSON.stringify({
codeVerifier: code_verifier,
appState
}));
// Build and encode the authorisation request url
const url = new URL(authorizationEndpoint);
return { state, code_challenge, url };
};
const createStore = () => {
let items = {};
const getItem = (key) => {
return items[key];
};
const setItem = (key, value) => {
items[key] = value;
};
const removeItem = (key) => {
delete items[key];
};
const reset = () => {
items = {};
};
return {
reset,
getItem,
removeItem,
setItem
};
};
const store = createStore();
const getClaim = (claim, tokenKey = 'access_token') => {
const token = store.getItem(`kinde_${tokenKey}`);
return token ? { name: claim, value: token[claim] } : null;
};
const getClaimValue = (claim, tokenKey = 'access_token') => {
const obj = getClaim(claim, tokenKey);
return obj && obj.value;
};
const getFlag = (code, defaultValue, flagType) => {
const flags = getClaimValue('feature_flags');
const flag = (flags && flags[code] ? flags[code] : {});
if (flag.v == null && defaultValue == null) {
throw Error(`Flag ${code} was not found, and no default value has been provided`);
}
if (flagType && flag.t && flagType !== flag.t) {
throw Error(`Flag ${code} is of type ${flagDataTypeMap[flag.t]} - requested type ${flagDataTypeMap[flagType]}`);
}
return {
code,
type: flagDataTypeMap[flag.t || flagType],
value: (flag.v == null ? defaultValue : flag.v),
is_default: flag.v == null
};
};
const getBooleanFlag = (code, defaultValue) => {
try {
const flag = getFlag(code, defaultValue, 'b');
return flag.value;
}
catch (err) {
console.error(err);
return err;
}
};
const getIntegerFlag = (code, defaultValue) => {
try {
const flag = getFlag(code, defaultValue, 'i');
return flag.value;
}
catch (err) {
console.error(err);
return err;
}
};
const getStringFlag = (code, defaultValue) => {
try {
const flag = getFlag(code, defaultValue, 's');
return flag.value;
}
catch (err) {
console.error(err);
return err;
}
};
const getUserOrganizations = () => {
const orgCodes = (getClaimValue('org_codes', 'id_token') ?? []);
return {
orgCodes
};
};
const PAYLOAD_CLAIMS = ['iss', 'azp'];
const isTokenValid = (token, config) => {
if (!token) {
throw new Error('ID token is required');
}
PAYLOAD_CLAIMS.forEach((claim) => {
if (!token.payload[claim]) {
throw new Error(`(${claim}) claim is required.`);
}
if (token.payload[claim] !== config[claim]) {
throw new Error(`${claim} claim mismatch. Expected: "${config[claim]}", Received: "${token.payload[claim]}"`);
}
});
if (token.header.alg !== 'RS256') {
throw new Error(`Unsupported signature alg. Expected: "RS256", Received: "${token.header.alg}"`);
}
if (config.aud) {
if (!Array.isArray(token.payload.aud)) {
throw new Error('(aud) claim must be an array');
}
const configAud = config.aud.split(' ');
const allConfigAudExistInPayload = configAud.every((element) => token.payload.aud.includes(element));
if (!allConfigAudExistInPayload) {
throw new Error(`(aud) claim mismatch. Expected: "${config.aud}", Received: "${token.payload.aud.join(', ')}"`);
}
}
const isJWTExpired = !isJWTActive(token.payload);
if (isJWTExpired) {
throw new Error(`Token expired`);
}
return true;
};
const isCustomDomain = (url) => {
const domain = new URL(url);
const bareDomain = domain.hostname.split('.').slice(-2).join('.');
return bareDomain !== 'kinde.com';
};
const createKindeClient = async (options) => {
if (!options) {
throw Error('Please provide your Kinde credentials');
}
if (options !== Object(options)) {
throw Error('The Kinde SDK must be initiated with an object');
}
const { audience, client_id: clientId, domain, is_dangerously_use_local_storage = false, redirect_uri, logout_uri = redirect_uri, on_redirect_callback, on_error_callback, scope = 'openid profile email offline', proxy_redirect_uri, _framework, _frameworkVersion } = options;
if (audience && typeof audience !== 'string') {
throw Error('Please supply a valid audience for your api');
}
if (scope && typeof scope !== 'string') {
throw Error('Please supply a valid scope');
}
if (!redirect_uri || typeof options.redirect_uri !== 'string') {
throw Error('Please supply a valid redirect_uri for your users to be redirected after successful authentication');
}
if (!domain || typeof domain !== 'string') {
throw Error('Please supply a valid Kinde domain so we can connect to your account');
}
if (typeof is_dangerously_use_local_storage !== 'boolean') {
throw TypeError('Please supply a boolean value for is_dangerously_use_local_storage');
}
const client_id = clientId || 'spa@live';
// If code is running on localhost, it's a development environment
const isDevelopment = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// Indicates using a custom domain on a production environment
const isUseCookie = !isDevelopment &&
!is_dangerously_use_local_storage &&
isCustomDomain(domain);
const isUseLocalStorage = isDevelopment || is_dangerously_use_local_storage;
const config = {
audience,
client_id,
redirect_uri,
authorization_endpoint: `${domain}/oauth2/auth`,
token_endpoint: `${domain}/oauth2/token`,
requested_scopes: scope,
domain,
_framework,
_frameworkVersion
};
const setStore = (data) => {
if (!data || data.error)
return;
const idToken = jwtDecode(data.id_token);
const idTokenHeader = jwtDecode(data.id_token, { header: true });
const accessToken = jwtDecode(data.access_token);
const accessTokenHeader = jwtDecode(data.access_token, { header: true });
const validatorOptions = {
iss: domain,
azp: clientId,
aud: audience
};
isTokenValid({
payload: idToken,
header: idTokenHeader
}, { ...validatorOptions, aud: clientId });
isTokenValid({
payload: accessToken,
header: accessTokenHeader
}, validatorOptions);
{
store.setItem(storageMap.token_bundle, data);
store.setItem(storageMap.access_token, accessToken);
store.setItem(storageMap.id_token, idToken);
if (idToken.sub) {
store.setItem(storageMap.user, {
id: idToken.sub,
given_name: idToken.given_name,
family_name: idToken.family_name,
email: idToken.email,
picture: idToken.picture
});
}
if (isUseLocalStorage) {
localStorage.setItem(storageMap.refresh_token, data.refresh_token);
}
else {
store.setItem(storageMap.refresh_token, data.refresh_token);
}
}
};
const useRefreshToken = async ({ tokenType } = { tokenType: storageMap.access_token }) => {
const localStorageRefreshToken = isUseLocalStorage
? localStorage.getItem(storageMap.refresh_token)
: store.getItem(storageMap.refresh_token);
const isCallTokenEndpoint = localStorageRefreshToken || (isUseCookie && hasCookie('_kbrte'));
if (isCallTokenEndpoint) {
try {
const response = await fetch(config.token_endpoint, {
method: 'POST',
...(isUseCookie && { credentials: 'include' }),
headers: new Headers({
'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Kinde-SDK': `
${config._framework || 'JavaScript'}/${config._frameworkVersion || version}`
}),
body: new URLSearchParams({
client_id: config.client_id,
grant_type: 'refresh_token',
...(!isUseCookie &&
localStorageRefreshToken && {
refresh_token: localStorageRefreshToken
})
})
});
const data = await response.json();
setStore(data);
if (tokenType === storageMap.id_token) {
return data.id_token;
}
return data.access_token;
}
catch (err) {
console.error(err);
}
}
};
const getTokenType = async (tokenType, options) => {
const token = store.getItem(storageMap.token_bundle);
if (!token || options.isForceRefresh) {
return await useRefreshToken({ tokenType });
}
const tokenToReturn = store.getItem(tokenType);
const isTokenActive = isJWTActive(tokenToReturn);
if (isTokenActive) {
return tokenType === storageMap.access_token
? token.access_token
: token.id_token;
}
else {
return await useRefreshToken({ tokenType });
}
};
const getToken = async (options = {}) => {
return await getTokenType(storageMap.access_token, options);
};
const getIdToken = async (options = {}) => {
return await getTokenType(storageMap.id_token, options);
};
const isAuthenticated = async () => {
const accessToken = store.getItem(storageMap.access_token);
if (!accessToken) {
return false;
}
const isTokenActive = isJWTActive(accessToken);
if (isTokenActive) {
return true;
}
await useRefreshToken();
return true;
};
const getPermissions = () => {
const orgCode = getClaimValue('org_code');
const permissions = (getClaimValue('permissions') ?? []);
return {
permissions,
orgCode
};
};
const getPermission = (key) => {
const orgCode = getClaimValue('org_code');
const permissions = (getClaimValue('permissions') ?? []);
return {
isGranted: permissions.some((p) => p === key),
orgCode
};
};
const getOrganization = () => {
const orgCode = getClaimValue('org_code');
return {
orgCode
};
};
const clearUrlParams = () => {
const url = new URL(window.location.toString());
url.search = '';
window.history.pushState({}, '', url);
};
const handleRedirectToApp = async (q) => {
const code = q.get('code');
const state = q.get('state');
const error = q.get('error');
if (error?.toLowerCase() === 'login_link_expired') {
const reauthState = q.get('reauth_state');
if (reauthState) {
const decodedAuthState = atob(reauthState);
try {
const reauthState = JSON.parse(decodedAuthState);
if (reauthState) {
login(reauthState);
}
}
catch (ex) {
throw new Error(ex instanceof Error
? ex.message
: 'Unknown Error parsing reauth state');
}
}
return;
}
const stringState = sessionStorage.getItem(`${SESSION_PREFIX}-${state}`);
// Verify state
if (!stringState) {
console.error('Invalid state');
}
else {
if (error) {
const error = q.get('error');
const errorDescription = q.get('error_description');
clearUrlParams();
sessionStorage.removeItem(`${SESSION_PREFIX}-${state}`);
const { appState } = JSON.parse(stringState);
if (on_error_callback) {
on_error_callback({
error,
errorDescription,
state,
appState
});
}
else {
window.location.href = appState.kindeOriginUrl;
}
return false;
}
const { appState, codeVerifier } = JSON.parse(stringState);
// Exchange authorisation code for an access token
try {
const response = await fetch(config.token_endpoint, {
method: 'POST',
...(isUseCookie && { credentials: 'include' }),
headers: new Headers({
'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Kinde-SDK': `${config._framework || 'JavaScript'}/${config._frameworkVersion || version}`
}),
body: new URLSearchParams({
client_id: config.client_id,
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: config.redirect_uri
})
});
const data = await response.json();
setStore(data);
// Remove auth code from address bar
clearUrlParams();
sessionStorage.removeItem(`${SESSION_PREFIX}-${state}`);
const user = getUser();
if (on_redirect_callback) {
on_redirect_callback(user, appState);
}
}
catch (err) {
console.error(err);
sessionStorage.removeItem(`${SESSION_PREFIX}-${state}`);
}
}
};
const redirectToKinde = async (options) => {
const { app_state = {}, prompt, is_create_org, org_name = '', org_code, authUrlParams = {} } = options;
if (!app_state.kindeOriginUrl) {
app_state.kindeOriginUrl = window.location.href;
}
const { state, code_challenge, url } = await setupChallenge(config.authorization_endpoint, app_state);
const searchParams = {
redirect_uri,
client_id,
response_type: 'code',
scope: config.requested_scopes,
code_challenge,
code_challenge_method: 'S256',
state,
supports_reauth: 'true'
};
if (prompt) {
searchParams.prompt = prompt;
}
if (org_code) {
searchParams.org_code = org_code;
}
if (is_create_org) {
searchParams.is_create_org = String(is_create_org);
searchParams.org_name = org_name;
}
const urlSearchParams = new URLSearchParams(Object.assign(authUrlParams, searchParams));
if (audience) {
/* if multiple audiences requested it should appear multiple times in the query string */
audience
.trim()
.split(/\s+/)
.forEach((aud) => {
urlSearchParams.append('audience', aud);
});
}
url.search = String(urlSearchParams);
window.location.href = url.toString();
};
const register = async (options) => {
await redirectToKinde({
...options,
prompt: 'create'
});
};
const login = async (options) => {
await redirectToKinde({
...options
});
};
const createOrg = async (options) => {
await redirectToKinde({
...options,
prompt: 'create',
is_create_org: true
});
};
const getUser = () => {
return store.getItem(storageMap.user);
};
const getUserProfile = async () => {
const token = await getToken();
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${token}`
};
try {
const res = await fetch(`${config.domain}/oauth2/v2/user_profile`, {
method: 'GET',
headers: headers
});
const json = await res.json();
store.setItem(storageMap.user, {
id: json.sub,
given_name: json.given_name,
family_name: json.family_name,
email: json.email,
picture: json.picture
});
return store.getItem(storageMap.user);
}
catch (err) {
console.error(err);
}
};
const logout = async () => {
const url = new URL(`${config.domain}/logout`);
try {
store.reset();
if (isUseLocalStorage) {
localStorage.removeItem(storageMap.refresh_token);
}
const searchParams = new URLSearchParams({
redirect: logout_uri
});
url.search = String(searchParams);
window.location.href = url.toString();
}
catch (err) {
console.error(err);
}
};
const init = async () => {
const q = new URLSearchParams(window.location.search);
// Is a redirect from Kinde Auth server
if (isKindeRedirect(q)) {
await handleRedirectToApp(q);
}
else {
// For onload / new tab / page refresh
if (isUseCookie || isUseLocalStorage) {
await useRefreshToken();
}
}
};
const isKindeRedirect = (searchParams) => {
// Check if the search params hve the code parameter
const hasOauthCode = searchParams.has('code');
const hasError = searchParams.has('error');
if (!hasOauthCode && !hasError)
return false;
// Also check if redirect_uri matches current url
const { protocol, host, pathname } = window.location;
const currentRedirectUri = proxy_redirect_uri || `${protocol}//${host}${pathname}`;
return (currentRedirectUri === redirect_uri ||
currentRedirectUri === `${redirect_uri}/`);
};
await init();
return {
getToken,
getIdToken,
getUser,
getUserProfile,
login,
logout,
register,
isAuthenticated,
createOrg,
getClaim,
getFlag,
getBooleanFlag,
getStringFlag,
getIntegerFlag,
getPermissions,
getPermission,
getOrganization,
getUserOrganizations
};
};
export { createKindeClient as default };