keycloak-js
Version:
A client-side JavaScript OpenID Connect library that can be used to secure web applications.
1,740 lines (1,490 loc) • 60.9 kB
JavaScript
// @ts-check
/**
* @import {Acr, KeycloakAccountOptions, KeycloakAdapter, KeycloakConfig, KeycloakError, KeycloakFlow, KeycloakInitOptions, KeycloakLoginOptions, KeycloakLogoutOptions, KeycloakPkceMethod, KeycloakProfile, KeycloakRegisterOptions, KeycloakResourceAccess, KeycloakResponseMode, KeycloakResponseType, KeycloakRoles, KeycloakTokenParsed, OpenIdProviderMetadata} from "./keycloak.ts"
*/
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const CONTENT_TYPE_JSON = 'application/json'
/**
* @typedef {Object} Endpoints
* @property {() => string} authorize
* @property {() => string} token
* @property {() => string} logout
* @property {() => string} checkSessionIframe
* @property {() => string=} thirdPartyCookiesIframe
* @property {() => string} register
* @property {() => string} userinfo
*/
/**
* @typedef {Object} LoginIframe
* @property {boolean} enable
* @property {((error: Error | null, value?: boolean) => void)[]} callbackList
* @property {number} interval
* @property {HTMLIFrameElement=} iframe
* @property {string=} iframeOrigin
*/
export default class Keycloak {
/** @type {Pick<PromiseWithResolvers<boolean>, 'resolve' | 'reject'>[]} */
#refreshQueue = []
/** @type {KeycloakAdapter} */
#adapter
/** @type {boolean} */
#useNonce = true
/** @type {CallbackStorage} */
#callbackStorage
#logInfo = this.#createLogger(console.info)
#logWarn = this.#createLogger(console.warn)
/** @type {LoginIframe} */
#loginIframe = {
enable: true,
callbackList: [],
interval: 5
}
/** @type {KeycloakConfig} config */
#config
didInitialize = false
authenticated = false
loginRequired = false
/** @type {KeycloakResponseMode} */
responseMode = 'fragment'
/** @type {KeycloakResponseType} */
responseType = 'code'
/** @type {KeycloakFlow} */
flow = 'standard'
/** @type {number?} */
timeSkew = null
/** @type {string=} */
redirectUri
/** @type {string=} */
silentCheckSsoRedirectUri
/** @type {boolean} */
silentCheckSsoFallback = true
/** @type {KeycloakPkceMethod} */
pkceMethod = 'S256'
enableLogging = false
/** @type {'GET' | 'POST'} */
logoutMethod = 'GET'
/** @type {string=} */
scope
messageReceiveTimeout = 10000
/** @type {string=} */
idToken
/** @type {KeycloakTokenParsed=} */
idTokenParsed
/** @type {string=} */
token
/** @type {KeycloakTokenParsed=} */
tokenParsed
/** @type {string=} */
refreshToken
/** @type {KeycloakTokenParsed=} */
refreshTokenParsed
/** @type {string=} */
clientId
/** @type {string=} */
sessionId
/** @type {string=} */
subject
/** @type {string=} */
authServerUrl
/** @type {string=} */
realm
/** @type {KeycloakRoles=} */
realmAccess
/** @type {KeycloakResourceAccess=} */
resourceAccess
/** @type {KeycloakProfile=} */
profile
/** @type {{}=} */
userInfo
/** @type {Endpoints} */
endpoints
/** @type {number=} */
tokenTimeoutHandle
/** @type {() => void=} */
onAuthSuccess
/** @type {(errorData?: KeycloakError) => void=} */
onAuthError
/** @type {() => void=} */
onAuthRefreshSuccess
/** @type {() => void=} */
onAuthRefreshError
/** @type {() => void=} */
onTokenExpired
/** @type {() => void=} */
onAuthLogout
/** @type {(authenticated: boolean) => void=} */
onReady
/** @type {(status: 'success' | 'cancelled' | 'error', action: string) => void=} */
onActionUpdate
/**
* @param {KeycloakConfig} config
*/
constructor (config) {
if (typeof config !== 'string' && !isObject(config)) {
throw new Error("The 'Keycloak' constructor must be provided with a configuration object, or a URL to a JSON configuration file.")
}
if (isObject(config)) {
const requiredProperties = 'oidcProvider' in config
? ['clientId']
: ['url', 'realm', 'clientId']
for (const property of requiredProperties) {
if (!(property in config)) {
throw new Error(`The configuration object is missing the required '${property}' property.`)
}
}
}
if (!globalThis.isSecureContext) {
this.#logWarn(
"[KEYCLOAK] Keycloak JS must be used in a 'secure context' to function properly as it relies on browser APIs that are otherwise not available.\n" +
'Continuing to run your application insecurely will lead to unexpected behavior and breakage.\n\n' +
'For more information see: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts'
)
}
this.#config = config
}
/**
* @param {KeycloakInitOptions} initOptions
* @returns {Promise<boolean>}
*/
init = async (initOptions = {}) => {
if (this.didInitialize) {
throw new Error("A 'Keycloak' instance can only be initialized once.")
}
this.didInitialize = true
this.#callbackStorage = createCallbackStorage()
const adapters = ['default', 'cordova', 'cordova-native']
if (typeof initOptions.adapter === 'string' && adapters.includes(initOptions.adapter)) {
this.#adapter = this.#loadAdapter(initOptions.adapter)
} else if (typeof initOptions.adapter === 'object') {
this.#adapter = initOptions.adapter
} else if ('Cordova' in window || 'cordova' in window) {
this.#adapter = this.#loadAdapter('cordova')
} else {
this.#adapter = this.#loadAdapter('default')
}
if (typeof initOptions.useNonce !== 'undefined') {
this.#useNonce = initOptions.useNonce
}
if (typeof initOptions.checkLoginIframe !== 'undefined') {
this.#loginIframe.enable = initOptions.checkLoginIframe
}
if (initOptions.checkLoginIframeInterval) {
this.#loginIframe.interval = initOptions.checkLoginIframeInterval
}
if (initOptions.onLoad === 'login-required') {
this.loginRequired = true
}
if (initOptions.responseMode) {
if (initOptions.responseMode === 'query' || initOptions.responseMode === 'fragment') {
this.responseMode = initOptions.responseMode
} else {
throw new Error('Invalid value for responseMode')
}
}
if (initOptions.flow) {
switch (initOptions.flow) {
case 'standard':
this.responseType = 'code'
break
case 'implicit':
this.responseType = 'id_token token'
break
case 'hybrid':
this.responseType = 'code id_token token'
break
default:
throw new Error('Invalid value for flow')
}
this.flow = initOptions.flow
}
if (typeof initOptions.timeSkew === 'number') {
this.timeSkew = initOptions.timeSkew
}
if (initOptions.redirectUri) {
this.redirectUri = initOptions.redirectUri
}
if (initOptions.silentCheckSsoRedirectUri) {
this.silentCheckSsoRedirectUri = initOptions.silentCheckSsoRedirectUri
}
if (typeof initOptions.silentCheckSsoFallback === 'boolean') {
this.silentCheckSsoFallback = initOptions.silentCheckSsoFallback
}
if (typeof initOptions.pkceMethod !== 'undefined') {
if (initOptions.pkceMethod !== 'S256' && initOptions.pkceMethod !== false) {
throw new TypeError(`Invalid value for pkceMethod', expected 'S256' or false but got ${initOptions.pkceMethod}.`)
}
this.pkceMethod = initOptions.pkceMethod
}
if (typeof initOptions.enableLogging === 'boolean') {
this.enableLogging = initOptions.enableLogging
}
if (initOptions.logoutMethod === 'POST') {
this.logoutMethod = 'POST'
}
if (typeof initOptions.scope === 'string') {
this.scope = initOptions.scope
}
if (typeof initOptions.messageReceiveTimeout === 'number' && initOptions.messageReceiveTimeout > 0) {
this.messageReceiveTimeout = initOptions.messageReceiveTimeout
}
await this.#loadConfig()
await this.#check3pCookiesSupported()
await this.#processInit(initOptions)
this.onReady?.(this.authenticated)
return this.authenticated
}
/**
* @param {"default" | "cordova" | "cordova-native"} type
* @returns {KeycloakAdapter}
*/
#loadAdapter (type) {
if (type === 'default') {
return this.#loadDefaultAdapter()
}
if (type === 'cordova') {
this.#loginIframe.enable = false
return this.#loadCordovaAdapter()
}
if (type === 'cordova-native') {
this.#loginIframe.enable = false
return this.#loadCordovaNativeAdapter()
}
throw new Error('invalid adapter type: ' + type)
}
/**
* @returns {KeycloakAdapter}
*/
#loadDefaultAdapter () {
/** @type {KeycloakAdapter['redirectUri']}{} */
const redirectUri = (options) => {
return options?.redirectUri || this.redirectUri || globalThis.location.href
}
return {
login: async (options) => {
window.location.assign(await this.createLoginUrl(options))
return await new Promise(() => {})
},
logout: async (options) => {
const logoutMethod = options?.logoutMethod ?? this.logoutMethod
if (logoutMethod === 'GET') {
window.location.replace(this.createLogoutUrl(options))
return
}
// Create form to send POST request.
const form = document.createElement('form')
form.setAttribute('method', 'POST')
form.setAttribute('action', this.createLogoutUrl(options))
form.style.display = 'none'
// Add data to form as hidden input fields.
const data = {
id_token_hint: this.idToken,
client_id: this.clientId,
post_logout_redirect_uri: redirectUri(options)
}
for (const [name, value] of Object.entries(data)) {
const input = document.createElement('input')
input.setAttribute('type', 'hidden')
input.setAttribute('name', name)
input.setAttribute('value', /** @type {string} */ (value))
form.appendChild(input)
}
// Append form to page and submit it to perform logout and redirect.
document.body.appendChild(form)
form.submit()
},
register: async (options) => {
window.location.assign(await this.createRegisterUrl(options))
return await new Promise(() => {})
},
accountManagement: async () => {
const accountUrl = this.createAccountUrl()
if (typeof accountUrl !== 'undefined') {
window.location.href = accountUrl
} else {
throw new Error('Not supported by the OIDC server')
}
return await new Promise(() => {})
},
redirectUri
}
}
/**
* @returns {KeycloakAdapter}
*/
#loadCordovaAdapter () {
/**
* @param {string} loginUrl
* @param {string} target
* @param {string} options
* @returns {WindowProxy | null}
*/
const cordovaOpenWindowWrapper = (loginUrl, target, options) => {
if (window.cordova && window.cordova.InAppBrowser) {
// Use inappbrowser for IOS and Android if available
return window.cordova.InAppBrowser.open(loginUrl, target, options)
} else {
return window.open(loginUrl, target, options)
}
}
const shallowCloneCordovaOptions = (userOptions) => {
if (userOptions && userOptions.cordovaOptions) {
return Object.keys(userOptions.cordovaOptions).reduce((options, optionName) => {
options[optionName] = userOptions.cordovaOptions[optionName]
return options
}, {})
} else {
return {}
}
}
const formatCordovaOptions = (cordovaOptions) => {
return Object.keys(cordovaOptions).reduce((options, optionName) => {
options.push(optionName + '=' + cordovaOptions[optionName])
return options
}, []).join(',')
}
const createCordovaOptions = (userOptions) => {
const cordovaOptions = shallowCloneCordovaOptions(userOptions)
cordovaOptions.location = 'no'
if (userOptions && userOptions.prompt === 'none') {
cordovaOptions.hidden = 'yes'
}
return formatCordovaOptions(cordovaOptions)
}
const getCordovaRedirectUri = () => {
return this.redirectUri || 'http://localhost'
}
return {
login: async (options) => {
const cordovaOptions = createCordovaOptions(options)
const loginUrl = await this.createLoginUrl(options)
const ref = cordovaOpenWindowWrapper(loginUrl, '_blank', cordovaOptions)
let completed = false
let closed = false
function closeBrowser () {
closed = true
ref.close()
};
return await new Promise((resolve, reject) => {
ref.addEventListener('loadstart', async (event) => {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
const callback = this.#parseCallback(event.url)
completed = true
closeBrowser()
try {
await this.#processCallback(callback)
resolve()
} catch (error) {
reject(error)
}
}
})
ref.addEventListener('loaderror', async (event) => {
if (!completed) {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
const callback = this.#parseCallback(event.url)
completed = true
closeBrowser()
try {
await this.#processCallback(callback)
resolve()
} catch (error) {
reject(error)
}
} else {
reject(new Error('Unable to process login.'))
closeBrowser()
}
}
})
ref.addEventListener('exit', function (event) {
if (!closed) {
reject(new Error('User closed the login window.'))
}
})
})
},
logout: async (options) => {
const logoutUrl = this.createLogoutUrl(options)
const ref = cordovaOpenWindowWrapper(logoutUrl, '_blank', 'location=no,hidden=yes,clearcache=yes')
let error = false
ref.addEventListener('loadstart', (event) => {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
ref.close()
}
})
ref.addEventListener('loaderror', (event) => {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
ref.close()
} else {
error = true
ref.close()
}
})
await new Promise((resolve, reject) => {
ref.addEventListener('exit', () => {
if (error) {
reject(new Error('User closed the login window.'))
} else {
this.clearToken()
resolve()
}
})
})
},
register: async (options) => {
const registerUrl = await this.createRegisterUrl()
const cordovaOptions = createCordovaOptions(options)
const ref = cordovaOpenWindowWrapper(registerUrl, '_blank', cordovaOptions)
/** @type {Promise<void>} */
const promise = new Promise((resolve, reject) => {
ref.addEventListener('loadstart', async (event) => {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
ref.close()
const oauth = this.#parseCallback(event.url)
try {
await this.#processCallback(oauth)
resolve()
} catch (error) {
reject(error)
}
}
})
})
await promise
},
accountManagement: async () => {
const accountUrl = this.createAccountUrl()
if (typeof accountUrl !== 'undefined') {
const ref = cordovaOpenWindowWrapper(accountUrl, '_blank', 'location=no')
ref.addEventListener('loadstart', function (event) {
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
ref.close()
}
})
} else {
throw new Error('Not supported by the OIDC server')
}
},
redirectUri: () => {
return getCordovaRedirectUri()
}
}
}
/**
* @returns {KeycloakAdapter}
*/
#loadCordovaNativeAdapter () {
/* global universalLinks */
return {
login: async (options) => {
const loginUrl = await this.createLoginUrl(options)
await new Promise((resolve, reject) => {
universalLinks.subscribe('keycloak', async (event) => {
universalLinks.unsubscribe('keycloak')
window.cordova.plugins.browsertab.close()
const oauth = this.#parseCallback(event.url)
try {
await this.#processCallback(oauth)
resolve()
} catch (error) {
reject(error)
}
})
window.cordova.plugins.browsertab.openUrl(loginUrl)
})
},
logout: async (options) => {
const logoutUrl = this.createLogoutUrl(options)
await new Promise((resolve) => {
universalLinks.subscribe('keycloak', () => {
universalLinks.unsubscribe('keycloak')
window.cordova.plugins.browsertab.close()
this.clearToken()
resolve()
})
window.cordova.plugins.browsertab.openUrl(logoutUrl)
})
},
register: async (options) => {
const registerUrl = await this.createRegisterUrl(options)
await new Promise((resolve, reject) => {
universalLinks.subscribe('keycloak', async (event) => {
universalLinks.unsubscribe('keycloak')
window.cordova.plugins.browsertab.close()
const oauth = this.#parseCallback(event.url)
try {
await this.#processCallback(oauth)
resolve()
} catch (error) {
reject(error)
}
})
window.cordova.plugins.browsertab.openUrl(registerUrl)
})
},
accountManagement: async () => {
const accountUrl = this.createAccountUrl()
if (typeof accountUrl !== 'undefined') {
window.cordova.plugins.browsertab.openUrl(accountUrl)
} else {
throw new Error('Not supported by the OIDC server')
}
},
redirectUri: (options) => {
if (options && options.redirectUri) {
return options.redirectUri
} else if (this.redirectUri) {
return this.redirectUri
} else {
return 'http://localhost'
}
}
}
}
/**
* @returns {Promise<void>}
*/
async #loadConfig () {
if (typeof this.#config === 'string') {
const jsonConfig = await fetchJsonConfig(this.#config)
this.authServerUrl = jsonConfig['auth-server-url']
this.realm = jsonConfig.realm
this.clientId = jsonConfig.resource
this.#setupEndpoints()
} else {
this.clientId = this.#config.clientId
if ('oidcProvider' in this.#config) {
await this.#loadOidcConfig(this.#config.oidcProvider)
} else {
this.authServerUrl = this.#config.url
this.realm = this.#config.realm
this.#setupEndpoints()
}
}
}
/**
* @returns {void}
*/
#setupEndpoints () {
this.endpoints = {
authorize: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/auth'
},
token: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/token'
},
logout: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/logout'
},
checkSessionIframe: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/login-status-iframe.html'
},
thirdPartyCookiesIframe: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/3p-cookies/step1.html'
},
register: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/registrations'
},
userinfo: () => {
return this.#getRealmUrl() + '/protocol/openid-connect/userinfo'
}
}
}
/**
* @param {string | OpenIdProviderMetadata} oidcProvider
* @returns {Promise<void>}
*/
async #loadOidcConfig (oidcProvider) {
if (typeof oidcProvider === 'string') {
const url = `${stripTrailingSlash(oidcProvider)}/.well-known/openid-configuration`
const openIdConfig = await fetchOpenIdConfig(url)
this.#setupOidcEndpoints(openIdConfig)
} else {
this.#setupOidcEndpoints(oidcProvider)
}
}
/**
* @param {OpenIdProviderMetadata} config
* @returns {void}
*/
#setupOidcEndpoints (config) {
this.endpoints = {
authorize () {
return config.authorization_endpoint
},
token () {
return config.token_endpoint
},
logout () {
if (!config.end_session_endpoint) {
throw new Error('Not supported by the OIDC server')
}
return config.end_session_endpoint
},
checkSessionIframe () {
if (!config.check_session_iframe) {
throw new Error('Not supported by the OIDC server')
}
return config.check_session_iframe
},
register () {
throw new Error('Redirection to "Register user" page not supported in standard OIDC mode')
},
userinfo () {
if (!config.userinfo_endpoint) {
throw new Error('Not supported by the OIDC server')
}
return config.userinfo_endpoint
}
}
}
/**
* @returns {Promise<void>}
*/
async #check3pCookiesSupported () {
if ((!this.#loginIframe.enable && !this.silentCheckSsoRedirectUri) || typeof this.endpoints.thirdPartyCookiesIframe !== 'function') {
return
}
const iframe = document.createElement('iframe')
iframe.setAttribute('src', this.endpoints.thirdPartyCookiesIframe())
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
iframe.setAttribute('title', 'keycloak-3p-check-iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
/** @type {Promise<void>} */
const promise = new Promise((resolve) => {
/**
* @param {MessageEvent} event
*/
const messageCallback = (event) => {
if (iframe.contentWindow !== event.source) {
return
}
if (event.data !== 'supported' && event.data !== 'unsupported') {
return
} else if (event.data === 'unsupported') {
this.#logWarn(
'[KEYCLOAK] Your browser is blocking access to 3rd-party cookies, this means:\n\n' +
' - It is not possible to retrieve tokens without redirecting to the Keycloak server (a.k.a. no support for silent authentication).\n' +
' - It is not possible to automatically detect changes to the session status (such as the user logging out in another tab).\n\n' +
'For more information see: https://www.keycloak.org/securing-apps/javascript-adapter#_modern_browsers'
)
this.#loginIframe.enable = false
if (this.silentCheckSsoFallback) {
this.silentCheckSsoRedirectUri = undefined
}
}
document.body.removeChild(iframe)
window.removeEventListener('message', messageCallback)
resolve()
}
window.addEventListener('message', messageCallback, false)
})
return await applyTimeoutToPromise(promise, this.messageReceiveTimeout, 'Timeout when waiting for 3rd party check iframe message.')
}
/**
* @param {KeycloakInitOptions} initOptions
* @returns {Promise<void>}
*/
async #processInit (initOptions) {
const callback = this.#parseCallback(window.location.href)
if (callback?.newUrl) {
window.history.replaceState(window.history.state, '', callback.newUrl)
}
if (callback && callback.valid) {
await this.#setupCheckLoginIframe()
await this.#processCallback(callback)
return
}
/** @param {boolean} prompt */
const doLogin = async (prompt) => {
/** @type {KeycloakLoginOptions} */
const options = {}
if (!prompt) {
options.prompt = 'none'
}
if (initOptions.locale) {
options.locale = initOptions.locale
}
await this.login(options)
}
const onLoad = async () => {
switch (initOptions.onLoad) {
case 'check-sso':
if (this.#loginIframe.enable) {
await this.#setupCheckLoginIframe()
const unchanged = await this.#checkLoginIframe()
if (!unchanged) {
this.silentCheckSsoRedirectUri ? await this.#checkSsoSilently() : await doLogin(false)
}
} else {
this.silentCheckSsoRedirectUri ? await this.#checkSsoSilently() : await doLogin(false)
}
break
case 'login-required':
await doLogin(true)
break
default:
throw new Error('Invalid value for onLoad')
}
}
if (initOptions.token && initOptions.refreshToken) {
this.#setToken(initOptions.token, initOptions.refreshToken, initOptions.idToken)
if (this.#loginIframe.enable) {
await this.#setupCheckLoginIframe()
const unchanged = await this.#checkLoginIframe()
if (unchanged) {
this.onAuthSuccess?.()
this.#scheduleCheckIframe()
}
} else {
try {
await this.updateToken(-1)
this.onAuthSuccess?.()
} catch (error) {
this.onAuthError?.()
if (initOptions.onLoad) {
await onLoad()
} else {
throw error
}
}
}
} else if (initOptions.onLoad) {
await onLoad()
}
}
/**
* @returns {Promise<void>}
*/
async #setupCheckLoginIframe () {
if (!this.#loginIframe.enable || this.#loginIframe.iframe) {
return
}
const iframe = document.createElement('iframe')
this.#loginIframe.iframe = iframe
iframe.setAttribute('src', this.endpoints.checkSessionIframe())
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
iframe.setAttribute('title', 'keycloak-session-iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
/**
* @param {MessageEvent} event
*/
const messageCallback = (event) => {
if (event.origin !== this.#loginIframe.iframeOrigin || this.#loginIframe.iframe?.contentWindow !== event.source) {
return
}
if (!(event.data === 'unchanged' || event.data === 'changed' || event.data === 'error')) {
return
}
if (event.data !== 'unchanged') {
this.clearToken()
}
const callbacks = this.#loginIframe.callbackList
this.#loginIframe.callbackList = []
for (const callback of callbacks.reverse()) {
if (event.data === 'error') {
callback(new Error('Error while checking login iframe'))
} else {
callback(null, event.data === 'unchanged')
}
}
}
window.addEventListener('message', messageCallback, false)
/** @type {Promise<void>} */
const promise = new Promise((resolve) => {
iframe.addEventListener('load', () => {
const authUrl = this.endpoints.authorize()
if (authUrl.startsWith('/')) {
this.#loginIframe.iframeOrigin = globalThis.location.origin
} else {
this.#loginIframe.iframeOrigin = new URL(authUrl).origin
}
resolve()
})
})
await promise
}
/**
* @returns {Promise<boolean | undefined>}
*/
async #checkLoginIframe () {
if (!this.#loginIframe.iframe || !this.#loginIframe.iframeOrigin) {
return
}
const message = `${this.clientId} ${(this.sessionId ? this.sessionId : '')}`
const origin = this.#loginIframe.iframeOrigin
/** @type {Promise<boolean>} */
const promise = new Promise((resolve, reject) => {
/** @type {(error: Error | null, value?: boolean) => void} */
const callback = (error, result) => error ? reject(error) : resolve(/** @type {boolean} */ (result))
this.#loginIframe.callbackList.push(callback)
if (this.#loginIframe.callbackList.length === 1) {
this.#loginIframe.iframe?.contentWindow?.postMessage(message, origin)
}
})
return await promise
}
/**
* @returns {Promise<void>}
*/
async #checkSsoSilently () {
const iframe = document.createElement('iframe')
const src = await this.createLoginUrl({ prompt: 'none', redirectUri: this.silentCheckSsoRedirectUri })
iframe.setAttribute('src', src)
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
iframe.setAttribute('title', 'keycloak-silent-check-sso')
iframe.style.display = 'none'
document.body.appendChild(iframe)
return await new Promise((resolve, reject) => {
/**
* @param {MessageEvent} event
*/
const messageCallback = async (event) => {
if (event.origin !== window.location.origin || iframe.contentWindow !== event.source) {
return
}
const oauth = this.#parseCallback(event.data)
try {
await this.#processCallback(oauth)
resolve()
} catch (error) {
reject(error)
}
document.body.removeChild(iframe)
window.removeEventListener('message', messageCallback)
}
window.addEventListener('message', messageCallback)
})
};
/**
* @param {string} url
*/
#parseCallback (url) {
const oauth = this.#parseCallbackUrl(url)
if (!oauth) {
return
}
const oauthState = this.#callbackStorage.get(oauth.state)
if (oauthState) {
oauth.valid = true
oauth.redirectUri = oauthState.redirectUri
oauth.storedNonce = oauthState.nonce
oauth.prompt = oauthState.prompt
oauth.pkceCodeVerifier = oauthState.pkceCodeVerifier
oauth.loginOptions = oauthState.loginOptions
}
return oauth
}
/**
* @param {string} urlString
*/
#parseCallbackUrl (urlString) {
let supportedParams = []
switch (this.flow) {
case 'standard':
supportedParams = ['code', 'state', 'session_state', 'kc_action_status', 'kc_action', 'iss']
break
case 'implicit':
supportedParams = ['access_token', 'token_type', 'id_token', 'state', 'session_state', 'expires_in', 'kc_action_status', 'kc_action', 'iss']
break
case 'hybrid':
supportedParams = ['access_token', 'token_type', 'id_token', 'code', 'state', 'session_state', 'expires_in', 'kc_action_status', 'kc_action', 'iss']
break
}
supportedParams.push('error')
supportedParams.push('error_description')
supportedParams.push('error_uri')
const url = new URL(urlString)
let newUrl = ''
let parsed
if (this.responseMode === 'query' && url.searchParams.size > 0) {
parsed = this.#parseCallbackParams(url.search, supportedParams)
url.search = parsed.paramsString
newUrl = url.toString()
} else if (this.responseMode === 'fragment' && url.hash.length > 0) {
parsed = this.#parseCallbackParams(url.hash.substring(1), supportedParams)
url.hash = parsed.paramsString
newUrl = url.toString()
}
if (parsed?.oauthParams) {
if (this.flow === 'standard' || this.flow === 'hybrid') {
if ((parsed.oauthParams.code || parsed.oauthParams.error) && parsed.oauthParams.state) {
parsed.oauthParams.newUrl = newUrl
return parsed.oauthParams
}
} else if (this.flow === 'implicit') {
if ((parsed.oauthParams.access_token || parsed.oauthParams.error) && parsed.oauthParams.state) {
parsed.oauthParams.newUrl = newUrl
return parsed.oauthParams
}
}
}
}
/**
* @typedef {Object} ParsedCallbackParams
* @property {string} paramsString
* @property {Record<string, string | undefined>} oauthParams
*/
/**
* @param {string} paramsString
* @param {string[]} supportedParams
* @returns {ParsedCallbackParams}
*/
#parseCallbackParams (paramsString, supportedParams) {
const params = paramsString.split('&')
/** @type {Record<string, string>} */
const oauthParams = {}
let result = ''
for (const param of params.reverse()) {
const entry = new URLSearchParams(param).entries().next().value
if (!entry) {
result = '&' + result
continue
}
const [key, value] = entry
if (supportedParams.includes(key) && !(key in oauthParams)) {
oauthParams[key] = value
} else {
result = result.length === 0 ? param : param + '&' + result
}
}
return {
paramsString: result,
oauthParams
}
}
async #processCallback (oauth) {
const { code, error, prompt } = oauth
let timeLocal = new Date().getTime()
/**
* @param {string} accessToken
* @param {string=} refreshToken
* @param {string=} idToken
*/
const authSuccess = (accessToken, refreshToken, idToken) => {
timeLocal = (timeLocal + new Date().getTime()) / 2
this.#setToken(accessToken, refreshToken, idToken, timeLocal)
if (this.#useNonce && (this.idTokenParsed && this.idTokenParsed.nonce !== oauth.storedNonce)) {
this.#logInfo('[KEYCLOAK] Invalid nonce, clearing token')
this.clearToken()
throw new Error('Invalid nonce.')
}
}
if (oauth.kc_action_status) {
this.onActionUpdate && this.onActionUpdate(oauth.kc_action_status, oauth.kc_action)
}
if (error) {
if (prompt !== 'none') {
if (oauth.error_description && oauth.error_description === 'authentication_expired') {
await this.login(oauth.loginOptions)
} else {
const errorData = { error, error_description: oauth.error_description }
this.onAuthError?.(errorData)
throw errorData
}
}
return
} else if ((this.flow !== 'standard') && (oauth.access_token || oauth.id_token)) {
authSuccess(oauth.access_token, undefined, oauth.id_token)
this.onAuthSuccess?.()
}
if ((this.flow !== 'implicit') && code) {
try {
const response = await fetchAccessToken(this.endpoints.token(), code, /** @type {string} */ (this.clientId), oauth.redirectUri, oauth.pkceCodeVerifier)
authSuccess(response.access_token, response.refresh_token, response.id_token)
if (this.flow === 'standard') {
this.onAuthSuccess?.()
}
this.#scheduleCheckIframe()
} catch (error) {
this.onAuthError?.()
throw error
}
}
}
async #scheduleCheckIframe () {
if (this.#loginIframe.enable && this.token) {
await waitForTimeout(this.#loginIframe.interval * 1000)
const unchanged = await this.#checkLoginIframe()
if (unchanged) {
await this.#scheduleCheckIframe()
}
}
}
/**
* @param {KeycloakLoginOptions} [options]
* @returns {Promise<void>}
*/
login = (options) => {
return this.#adapter.login(options)
}
/**
* @param {KeycloakLoginOptions} [options]
* @returns {Promise<string>}
*/
createLoginUrl = async (options) => {
const state = createUUID()
const nonce = createUUID()
const redirectUri = this.#adapter.redirectUri(options)
/** @type {CallbackState} */
const callbackState = {
state,
nonce,
redirectUri,
loginOptions: options
}
if (options?.prompt) {
callbackState.prompt = options.prompt
}
const url = options?.action === 'register'
? this.endpoints.register()
: this.endpoints.authorize()
let scope = options?.scope || this.scope
const scopeValues = scope ? scope.split(' ') : []
// Ensure the 'openid' scope is always included.
if (!scopeValues.includes('openid')) {
scopeValues.unshift('openid')
}
scope = scopeValues.join(' ')
const params = new URLSearchParams([
['client_id', /** @type {string} */ (this.clientId)],
['redirect_uri', redirectUri],
['state', state],
['response_mode', this.responseMode],
['response_type', this.responseType],
['scope', scope]
])
if (this.#useNonce) {
params.append('nonce', nonce)
}
if (options?.prompt) {
params.append('prompt', options.prompt)
}
if (typeof options?.maxAge === 'number') {
params.append('max_age', options.maxAge.toString())
}
if (options?.loginHint) {
params.append('login_hint', options.loginHint)
}
if (options?.idpHint) {
params.append('kc_idp_hint', options.idpHint)
}
if (options?.action && options.action !== 'register') {
params.append('kc_action', options.action)
}
if (options?.locale) {
params.append('ui_locales', options.locale)
}
if (options?.acr) {
params.append('claims', buildClaimsParameter(options.acr))
}
if (options?.acrValues) {
params.append('acr_values', options.acrValues)
}
if (this.pkceMethod) {
try {
const codeVerifier = generateCodeVerifier(96)
const pkceChallenge = await generatePkceChallenge(this.pkceMethod, codeVerifier)
callbackState.pkceCodeVerifier = codeVerifier
params.append('code_challenge', pkceChallenge)
params.append('code_challenge_method', this.pkceMethod)
} catch (error) {
throw new Error('Failed to generate PKCE challenge.', { cause: error })
}
}
this.#callbackStorage.add(callbackState)
return `${url}?${params.toString()}`
}
/**
* @param {KeycloakLogoutOptions} [options]
* @returns {Promise<void>}
*/
logout = (options) => {
return this.#adapter.logout(options)
}
/**
* @param {KeycloakLogoutOptions} [options]
* @returns {string}
*/
createLogoutUrl = (options) => {
const logoutMethod = options?.logoutMethod ?? this.logoutMethod
const url = this.endpoints.logout()
if (logoutMethod === 'POST') {
return url
}
const params = new URLSearchParams([
['client_id', /** @type {string} */ (this.clientId)],
['post_logout_redirect_uri', this.#adapter.redirectUri(options)]
])
if (this.idToken) {
params.append('id_token_hint', this.idToken)
}
return `${url}?${params.toString()}`
}
/**
* @param {KeycloakRegisterOptions} [options]
* @returns {Promise<void>}
*/
register = (options) => {
return this.#adapter.register(options)
}
/**
* @param {KeycloakRegisterOptions} [options]
* @returns {Promise<string>}
*/
createRegisterUrl = (options) => {
return this.createLoginUrl({ ...options, action: 'register' })
}
/**
* @param {KeycloakAccountOptions} [options]
* @returns {string}
*/
createAccountUrl = (options) => {
const url = this.#getRealmUrl()
if (!url) {
throw new Error('Unable to create account URL, make sure the adapter is not configured using a generic OIDC provider.')
}
const params = new URLSearchParams([
['referrer', /** @type {string} */ (this.clientId)],
['referrer_uri', this.#adapter.redirectUri(options)]
])
return `${url}/account?${params.toString()}`
}
/**
* @returns {Promise<void>}
*/
accountManagement = () => {
return this.#adapter.accountManagement()
}
/**
* @param {string} role
* @returns {boolean}
*/
hasRealmRole = (role) => {
const access = this.realmAccess
return !!access && access.roles.indexOf(role) >= 0
}
/**
* @param {string} role
* @param {string} [resource]
* @returns {boolean}
*/
hasResourceRole = (role, resource) => {
if (!this.resourceAccess) {
return false
}
const access = this.resourceAccess[resource || /** @type {string} */ (this.clientId)]
return !!access && access.roles.indexOf(role) >= 0
}
/**
* @returns {Promise<KeycloakProfile>}
*/
loadUserProfile = async () => {
const realmUrl = this.#getRealmUrl()
if (!realmUrl) {
throw new Error('Unable to load user profile, make sure the adapter is not configured using a generic OIDC provider.')
}
const url = `${realmUrl}/account`
/** @type {KeycloakProfile} */
const profile = await fetchJSON(url, {
headers: [buildAuthorizationHeader(this.token)]
})
return (this.profile = profile)
}
/**
* @returns {Promise<{}>}
*/
loadUserInfo = async () => {
const url = this.endpoints.userinfo()
/** @type {{}} */
const userInfo = await fetchJSON(url, {
headers: [buildAuthorizationHeader(this.token)]
})
return (this.userInfo = userInfo)
}
/**
* @param {number} [minValidity]
* @returns {boolean}
*/
isTokenExpired = (minValidity) => {
if (!this.tokenParsed || (!this.refreshToken && this.flow !== 'implicit')) {
throw new Error('Not authenticated')
}
if (this.timeSkew == null) {
this.#logInfo('[KEYCLOAK] Unable to determine if token is expired as timeskew is not set')
return true
}
if (typeof this.tokenParsed.exp !== 'number') {
return false
}
let expiresIn = this.tokenParsed.exp - Math.ceil(new Date().getTime() / 1000) + this.timeSkew
if (minValidity) {
if (isNaN(minValidity)) {
throw new Error('Invalid minValidity')
}
expiresIn -= minValidity
}
return expiresIn < 0
}
/**
* @param {number} minValidity
* @returns {Promise<boolean>}
*/
updateToken = async (minValidity) => {
if (!this.refreshToken) {
throw new Error('Unable to update token, no refresh token available.')
}
minValidity = minValidity || 5
if (this.#loginIframe.enable) {
await this.#checkLoginIframe()
}
let refreshToken = false
if (minValidity === -1) {
refreshToken = true
this.#logInfo('[KEYCLOAK] Refreshing token: forced refresh')
} else if (!this.tokenParsed || this.isTokenExpired(minValidity)) {
refreshToken = true
this.#logInfo('[KEYCLOAK] Refreshing token: token expired')
}
if (!refreshToken) {
return false
}
/** @type {PromiseWithResolvers<boolean>} */
const { promise, resolve, reject } = Promise.withResolvers()
this.#refreshQueue.push({ resolve, reject })
if (this.#refreshQueue.length === 1) {
const url = this.endpoints.token()
let timeLocal = new Date().getTime()
try {
const response = await fetchRefreshToken(url, this.refreshToken, /** @type {string} */ (this.clientId))
this.#logInfo('[KEYCLOAK] Token refreshed')
timeLocal = (timeLocal + new Date().getTime()) / 2
this.#setToken(response.access_token, response.refresh_token, response.id_token, timeLocal)
this.onAuthRefreshSuccess?.()
for (let p = this.#refreshQueue.pop(); p != null; p = this.#refreshQueue.pop()) {
p.resolve(true)
}
} catch (error) {
this.#logWarn('[KEYCLOAK] Failed to refresh token')
if (error instanceof NetworkError && error.response.status === 400) {
this.clearToken()
}
this.onAuthRefreshError?.()
for (let p = this.#refreshQueue.pop(); p != null; p = this.#refreshQueue.pop()) {
p.reject(error)
}
}
}
return await promise
}
clearToken = () => {
if (this.token) {
this.#setToken()
this.onAuthLogout?.()
if (this.loginRequired) {
this.login()
}
}
}
/**
* @param {string} [token]
* @param {string} [refreshToken]
* @param {string} [idToken]
* @param {number} [timeLocal]
*/
#setToken (token, refreshToken, idToken, timeLocal) {
if (this.tokenTimeoutHandle) {
clearTimeout(this.tokenTimeoutHandle)
this.tokenTimeoutHandle = undefined
}
if (refreshToken) {
this.refreshToken = refreshToken
this.refreshTokenParsed = decodeToken(refreshToken)
} else {
delete this.refreshToken
delete this.refreshTokenParsed
}
if (idToken) {
this.idToken = idToken
this.idTokenParsed = decodeToken(idToken)
} else {
delete this.idToken
delete this.idTokenParsed
}
if (token) {
this.token = token
this.tokenParsed = decodeToken(token)
this.sessionId = this.tokenParsed.sid
this.authenticated = true
this.subject = this.tokenParsed.sub
this.realmAccess = this.tokenParsed.realm_access
this.resourceAccess = this.tokenParsed.resource_access
if (timeLocal) {
this.timeSkew = Math.floor(timeLocal / 1000) - this.tokenParsed.iat
}
if (this.timeSkew !== null) {
this.#logInfo('[KEYCLOAK] Estimated time difference between browser and server is ' + this.timeSkew + ' seconds')
if (this.onTokenExpired) {
const expiresIn = (this.tokenParsed.exp - (new Date().getTime() / 1000) + this.timeSkew) * 1000
this.#logInfo('[KEYCLOAK] Token expires in ' + Math.round(expiresIn / 1000) + ' s')
if (expiresIn <= 0) {
this.onTokenExpired()
} else {
this.tokenTimeoutHandle = window.setTimeout(this.onTokenExpired, expiresIn)
}
}
}
} else {
delete this.token
delete this.tokenParsed
delete this.subject
delete this.realmAccess
delete this.resourceAccess
this.authenticated = false
}
}
/**
* @returns {string=}
*/
#getRealmUrl () {
if (typeof this.authServerUrl === 'undefined') {
return
}
return `${stripTrailingSlash(this.authServerUrl)}/realms/${encodeURIComponent(/** @type {string} */ (this.realm))}`
}
/**
* @param {Function} fn
* @returns {(message: string) => void}
*/
#createLogger (fn) {
return (message) => {
if (this.enableLogging) {
fn.call(console, message)
}
}
}
}
/**
* @returns {string}
*/
function createUUID () {
if (typeof crypto === 'undefined' || typeof crypto.randomUUID === 'undefined') {
throw new Error('Web Crypto API is not available.')
}
return crypto.randomUUID()
}
/**
* @param {Acr} requestedAcr
* @returns {string}
*/
function buildClaimsParameter (requestedAcr) {
return JSON.stringify({
id_token: {
acr: requestedAcr
}
})
}
/**
* @param {number} len
* @returns {string}
*/
function generateCodeVerifier (len) {
return generateRandomString(len, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
}
/**
* @param {string} pkceMethod
* @param {string} codeVerifier
* @returns {Promise<string>}
*/
async function generatePkceChallenge (pkceMethod, codeVerifier) {
if (pkceMethod !== 'S256') {
throw new TypeError(`Invalid value for 'pkceMethod', expected 'S256' but got '${pkceMethod}'.`)
}
// hash codeVerifier, then encode as url-safe base64 without padding
const hashBytes = new Uint8Array(await sha256Digest(codeVerifier))
const encodedHash = bytesToBase64(hashBytes)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
return encodedHash
}
/**
* @param {number} len
* @param {string} alphabet
* @returns {string}
*/
function generateRandomString (len, alphabet) {
const randomData = generateRandomData(len)
const chars = new Array(len)
for (let i = 0; i < len; i++) {
chars[i] = alphabet.charCodeAt(randomData[i] % alphabet.length)
}
return String.fromCharCode.apply(null, chars)
}
/**
* @param {number} len
* @returns {Uint8Array<ArrayBuffer>}
*/
function generateRandomData (len) {
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues === 'undefined') {
throw new Error('Web Crypto API is not available.')
}
return crypto.getRandomValues(new Uint8Array(len))
}
/**
* Function to extend existing native Promise with timeout
*
* @template T
* @param {Promise<T>} promise
* @param {number} timeout
* @param {string} errorMessage
* @returns {Promise<T>}
*/
function applyTimeoutToPromise (promise, timeout, errorMessage) {
/** @type {number} */
let timeoutHandle
const timeoutPromise = new Promise(function (resolve, reject) {
timeoutHandle = window.setTimeout(function () {
reject(new Error(errorMessage || 'Promise is not settled within timeout of ' + timeout + 'ms'))
}, timeout)
})
return Promise.race([promise, timeoutPromise]).finally(function () {
clearTimeout(timeoutHandle)
})
}
/**
* @returns {CallbackStorage}
*/
function createCallbackStorage () {
try {
return new LocalStorage()
} catch (err) {
return new CookieStorage()
}
}
const STORAGE_KEY_PREFIX = 'kc-callback-'
/**
* @typedef {Object} CallbackState
* @property {string} state
* @property {string} nonce
* @property {string} redirectUri
* @property {KeycloakLoginOptions} [loginOptions]
* @property {KeycloakLoginOptions['prompt']} [prompt]
* @property {string} [