UNPKG

@kinde-oss/kinde-auth-pkce-js

Version:

Kinde PKCE authentication for SPAs

678 lines (656 loc) 23.5 kB
// 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 };