@react-keycloak/keycloak-ts
Version:
Keycloak typescript adapter
910 lines (714 loc) • 26.7 kB
JavaScript
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
import Deferred from './utils/deferred';
import { decodeToken, getRealmUrl, isKeycloakConfig, setupOidcEndoints, parseCallbackParams } from './utils/keycloak';
import { formatQuerystringParameters } from './utils/url';
import { createUUID, generateCodeVerifier, generatePkceChallenge } from './utils/uuid';
/**
* A client for the Keycloak authentication server.
* @see {@link https://keycloak.gitbooks.io/securing-client-applications-guide/content/topics/oidc/javascript-adapter.html|Keycloak JS adapter documentation}
*/
export class KeycloakClient {
// KeycloakUserInfo;
constructor(clientConfig) {
_defineProperty(this, "authenticated", void 0);
_defineProperty(this, "subject", void 0);
_defineProperty(this, "responseMode", void 0);
_defineProperty(this, "responseType", void 0);
_defineProperty(this, "flow", void 0);
_defineProperty(this, "realmAccess", void 0);
_defineProperty(this, "resourceAccess", void 0);
_defineProperty(this, "token", void 0);
_defineProperty(this, "tokenParsed", void 0);
_defineProperty(this, "refreshToken", void 0);
_defineProperty(this, "refreshTokenParsed", void 0);
_defineProperty(this, "idToken", void 0);
_defineProperty(this, "idTokenParsed", void 0);
_defineProperty(this, "timeSkew", void 0);
_defineProperty(this, "loginRequired", void 0);
_defineProperty(this, "authServerUrl", void 0);
_defineProperty(this, "realm", void 0);
_defineProperty(this, "clientId", void 0);
_defineProperty(this, "redirectUri", void 0);
_defineProperty(this, "profile", void 0);
_defineProperty(this, "userInfo", void 0);
_defineProperty(this, "enableLogging", void 0);
_defineProperty(this, "tokenTimeoutHandle", void 0);
_defineProperty(this, "endpoints", void 0);
_defineProperty(this, "clientConfig", void 0);
_defineProperty(this, "adapter", void 0);
_defineProperty(this, "callbackStorage", void 0);
_defineProperty(this, "logInfo", this.createLogger(console.info));
_defineProperty(this, "logWarn", this.createLogger(console.warn));
_defineProperty(this, "refreshQueue", []);
_defineProperty(this, "useNonce", void 0);
_defineProperty(this, "pkceMethod", void 0);
this.clientConfig = clientConfig;
}
/**
* Called to initialize the adapter.
* @param initOptions Initialization options.
* @returns A promise to set functions to be invoked on success or error.
*/
async init(initOptions) {
this.authenticated = false;
if (!initOptions.adapter) {
throw new Error('Missing Keycloak adapter from initOptions');
}
this.adapter = new initOptions.adapter(this, this.clientConfig, initOptions);
this.callbackStorage = this.adapter.createCallbackStorage();
if (initOptions) {
if (typeof initOptions.useNonce !== 'undefined') {
this.useNonce = initOptions.useNonce;
}
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 (initOptions.timeSkew != null) {
this.timeSkew = initOptions.timeSkew;
}
if (initOptions.redirectUri) {
this.redirectUri = initOptions.redirectUri;
}
if (initOptions.pkceMethod) {
if (initOptions.pkceMethod !== 'S256') {
throw new Error('Invalid value for pkceMethod');
}
this.pkceMethod = initOptions.pkceMethod;
}
if (typeof initOptions.enableLogging === 'boolean') {
this.enableLogging = initOptions.enableLogging;
} else {
this.enableLogging = false;
}
}
if (!this.responseMode) {
this.responseMode = 'fragment';
}
if (!this.responseType) {
this.responseType = 'code';
this.flow = 'standard';
}
await this.loadConfig(this.clientConfig); // await check3pCookiesSupported(); // Not supported on RN
await this.processInit(initOptions); // Notify onReady event handler if set
this.onReady && this.onReady(this.authenticated); // Return authentication status
return this.authenticated;
}
/**
* Redirects to login form.
* @param options Login options.
*/
async login(options) {
return this.adapter.login(options);
}
/**
* Redirects to logout.
* @param options Logout options.
*/
async logout(options) {
return this.adapter.logout(options);
}
/**
* Redirects to registration form.
* @param options The options used for the registration.
*/
async register(options) {
return this.adapter.register(options);
}
/**
* Redirects to the Account Management Console.
*/
async accountManagement() {
return this.adapter.accountManagement();
}
/**
* Returns the URL to login form.
* @param options Supports same options as Keycloak#login.
*/
createLoginUrl(options) {
var _options$prompt;
const state = createUUID();
const nonce = createUUID();
const redirectUri = this.adapter.redirectUri(options);
const {
scope: scopeOption,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
redirectUri: redirectUriOption,
prompt,
action,
maxAge,
loginHint,
idpHint,
locale,
...rest
} = options !== null && options !== void 0 ? options : {};
let codeVerifier;
let pkceChallenge;
if (this.pkceMethod) {
codeVerifier = generateCodeVerifier(96);
pkceChallenge = generatePkceChallenge(this.pkceMethod, codeVerifier);
}
const callbackState = {
state,
nonce,
pkceCodeVerifier: codeVerifier,
prompt: (_options$prompt = options === null || options === void 0 ? void 0 : options.prompt) !== null && _options$prompt !== void 0 ? _options$prompt : undefined,
redirectUri
};
let scope;
if (scopeOption) {
if (scopeOption.indexOf('openid') !== -1) {
scope = scopeOption;
} else {
scope = 'openid ' + scopeOption;
}
} else {
scope = 'openid';
}
const baseUrl = action === 'register' ? this.endpoints.register() : this.endpoints.authorize();
const params = new Map();
params.set('client_id', this.clientId);
params.set('redirect_uri', redirectUri);
params.set('state', state);
params.set('response_mode', this.responseMode);
params.set('response_type', this.responseType);
params.set('scope', scope);
if (this.useNonce) {
params.set('nonce', nonce);
}
if (prompt) {
params.set('prompt', prompt);
}
if (maxAge) {
params.set('max_age', `${maxAge}`);
}
if (loginHint) {
params.set('login_hint', loginHint);
}
if (idpHint) {
params.set('kc_idp_hint', idpHint);
}
if (action && action !== 'register') {
params.set('kc_action', action);
}
if (locale) {
params.set('ui_locales', locale);
}
if (this !== null && this !== void 0 && this.pkceMethod && !!pkceChallenge) {
params.set('code_challenge', pkceChallenge);
params.set('code_challenge_method', this.pkceMethod);
}
this.callbackStorage.add(callbackState);
Object.keys(rest).forEach(key => {
params.set(key, `${rest[key]}`);
});
return `${baseUrl}?${formatQuerystringParameters(params)}`;
}
/**
* Returns the URL to logout the user.
* @param options Logout options.
*/
createLogoutUrl(options) {
const params = new Map();
params.set('redirect_uri', this.adapter.redirectUri(options));
return `${this.endpoints.logout()}?${formatQuerystringParameters(params)}`;
}
/**
* Returns the URL to registration page.
* @param options The options used for creating the registration URL.
*/
createRegisterUrl() {
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return this.createLoginUrl({ ...options,
action: 'register'
});
}
/**
* Returns the URL to the Account Management Console.
*/
createAccountUrl() {
const realm = getRealmUrl(this.realm, this.authServerUrl);
if (typeof realm === 'undefined') {
throw new Error('Failed to create Account URL. realm is not defined.');
}
const params = new Map();
params.set('referrer', this.clientId);
params.set('referrer_uri', this.adapter.redirectUri());
return `${realm}/account?${formatQuerystringParameters(params)}`;
}
/**
* Returns true if the token has less than `minValidity` seconds left before
* it expires.
* @param minValidity If not specified, `0` is used.
*/
isTokenExpired(minValidity) {
var _this$tokenParsed$exp, _this$tokenParsed;
if (!this.tokenParsed || !this.refreshToken && this.flow !== 'implicit') {
throw 'Not authenticated';
}
if (this.timeSkew == null) {
this.logInfo('[KEYCLOAK] Unable to determine if token is expired as timeskew is not set');
return true;
}
let expiresIn = ((_this$tokenParsed$exp = (_this$tokenParsed = this.tokenParsed) === null || _this$tokenParsed === void 0 ? void 0 : _this$tokenParsed.exp) !== null && _this$tokenParsed$exp !== void 0 ? _this$tokenParsed$exp : 0) - Math.ceil(new Date().getTime() / 1000) + this.timeSkew;
if (minValidity) {
if (isNaN(minValidity)) {
throw 'Invalid minValidity';
}
expiresIn -= minValidity;
}
return expiresIn < 0;
}
async runUpdateToken(minValidity, deffered) {
let shouldRefreshToken = false;
if (minValidity === -1) {
shouldRefreshToken = true;
this.logInfo('[KEYCLOAK] Refreshing token: forced refresh');
} else if (!this.tokenParsed || this.isTokenExpired(minValidity)) {
shouldRefreshToken = true;
this.logInfo('[KEYCLOAK] Refreshing token: token expired');
}
if (!shouldRefreshToken) {
deffered.resolve(false);
} else {
const tokenUrl = this.endpoints.token();
const params = new Map();
params.set('client_id', this.clientId);
params.set('grant_type', 'refresh_token');
params.set('refresh_token', this.refreshToken);
this.refreshQueue.push(deffered);
if (this.refreshQueue.length === 1) {
let timeLocal = new Date().getTime();
try {
const tokenResponse = await this.adapter.refreshTokens(tokenUrl, formatQuerystringParameters(params));
if (tokenResponse.error) {
this.clearToken();
throw new Error(tokenResponse.error);
} else {
this.logInfo('[KEYCLOAK] Token refreshed');
timeLocal = (timeLocal + new Date().getTime()) / 2;
this.setToken(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.id_token, timeLocal); // Notify onAuthRefreshSuccess event handler if set
this.onAuthRefreshSuccess && this.onAuthRefreshSuccess();
for (let p = this.refreshQueue.pop(); p != null; p = this.refreshQueue.pop()) {
p.resolve(true);
}
}
} catch (err) {
this.logWarn('[KEYCLOAK] Failed to refresh token'); // Notify onAuthRefreshError event handler if set
this.onAuthRefreshError && this.onAuthRefreshError();
for (let p = this.refreshQueue.pop(); p != null; p = this.refreshQueue.pop()) {
p.reject(true);
}
}
}
}
}
/**
* If the token expires within `minValidity` seconds, the token is refreshed.
* If the session status iframe is enabled, the session status is also
* checked.
* @returns A promise to set functions that can be invoked if the token is
* still valid, or if the token is no longer valid.
* @example
* ```js
* keycloak.updateToken(5).then(function(refreshed) {
* if (refreshed) {
* alert('Token was successfully refreshed');
* } else {
* alert('Token is still valid');
* }
* }).catch(function() {
* alert('Failed to refresh the token, or the session has expired');
* });
*/
async updateToken() {
let minValidity = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 5;
const deffered = new Deferred();
if (!this.refreshToken) {
deffered.reject('missing refreshToken');
return deffered.getPromise();
}
this.runUpdateToken(minValidity, deffered);
return deffered.getPromise();
}
/**
* Clears authentication state, including tokens. This can be useful if
* the application has detected the session was expired, for example if
* updating token fails. Invoking this results in Keycloak#onAuthLogout
* callback listener being invoked.
*/
clearToken() {
if (this.token) {
this.setToken(null, null, null); // Notify onAuthLogout event handler if set
this.onAuthLogout && this.onAuthLogout();
if (this.loginRequired) {
this.login();
}
}
}
/**
* Returns true if the token has the given realm role.
* @param role A realm role name.
*/
hasRealmRole(role) {
var _this$realmAccess$rol;
return !!this.realmAccess && ((_this$realmAccess$rol = this.realmAccess.roles) === null || _this$realmAccess$rol === void 0 ? void 0 : _this$realmAccess$rol.indexOf(role)) >= 0;
}
/**
* Returns true if the token has the given role for the resource.
* @param role A role name.
* @param resource If not specified, `clientId` is used.
*/
hasResourceRole(role, resource) {
if (!this.resourceAccess) {
return false;
}
const access = this.resourceAccess[resource || this.clientId || ''];
return !!access && access.roles.indexOf(role) >= 0;
}
/**
* Loads the user's profile.
*
* @returns The current user KeycloakProfile.
*/
async loadUserProfile() {
const profileUrl = getRealmUrl(this.realm, this.authServerUrl) + '/account';
const userProfileRes = await this.adapter.fetchUserProfile(profileUrl, this.token);
this.profile = userProfileRes;
return this.profile;
}
/**
* @private Undocumented.
*/
async loadUserInfo() {
const userInfoUrl = this.endpoints.userinfo();
const userInfoRes = await this.adapter.fetchUserInfo(userInfoUrl, this.token);
this.userInfo = userInfoRes;
return this.userInfo;
}
/**
* Called when the adapter is initialized.
*/
/**
* @private Undocumented.
*/
async processCallback(oauth) {
const timeLocal = new Date().getTime();
if (oauth.kc_action_status) {
this.onActionUpdate && this.onActionUpdate(oauth.kc_action_status);
}
const {
code,
error,
prompt
} = oauth;
if (error) {
if (prompt !== 'none') {
var _oauth$error_descript;
this.onAuthError && this.onAuthError({
error,
error_description: (_oauth$error_descript = oauth.error_description) !== null && _oauth$error_descript !== void 0 ? _oauth$error_descript : 'auth error'
});
throw new Error(oauth.error_description);
}
return;
}
if (this.flow !== 'standard' && (oauth.access_token || oauth.id_token)) {
return this.authSuccess(oauth, timeLocal, true);
}
if (this.flow !== 'implicit' && code) {
const params = new Map();
params.set('code', code);
params.set('grant_type', 'authorization_code');
params.set('client_id', this.clientId);
params.set('redirect_uri', oauth.redirectUri);
if (oauth.pkceCodeVerifier) {
params.set('code_verifier', oauth.pkceCodeVerifier);
}
const tokenUrl = this.endpoints.token();
try {
const tokenResponse = await this.adapter.fetchTokens(tokenUrl, formatQuerystringParameters(params));
await this.authSuccess({ ...oauth,
access_token: tokenResponse.access_token || undefined,
refresh_token: tokenResponse.refresh_token || undefined,
id_token: tokenResponse.id_token || undefined
}, timeLocal, this.flow === 'standard');
} catch (err) {
// Notify onAuthError event handler if set
this.onAuthError && this.onAuthError({
error: err,
error_description: 'Failed to refresh token during callback processing'
});
throw new Error(err);
}
}
}
async authSuccess(oauthObj, timeLocal, fulfillPromise) {
var _oauthObj$access_toke, _oauthObj$refresh_tok, _oauthObj$id_token;
timeLocal = (timeLocal + new Date().getTime()) / 2;
this.setToken((_oauthObj$access_toke = oauthObj.access_token) !== null && _oauthObj$access_toke !== void 0 ? _oauthObj$access_toke : null, (_oauthObj$refresh_tok = oauthObj.refresh_token) !== null && _oauthObj$refresh_tok !== void 0 ? _oauthObj$refresh_tok : null, (_oauthObj$id_token = oauthObj.id_token) !== null && _oauthObj$id_token !== void 0 ? _oauthObj$id_token : null, timeLocal);
if (this.useNonce && (this.tokenParsed && this.tokenParsed.nonce !== oauthObj.storedNonce || this.refreshTokenParsed && this.refreshTokenParsed.nonce !== oauthObj.storedNonce || this.idTokenParsed && this.idTokenParsed.nonce !== oauthObj.storedNonce)) {
this.logInfo('[KEYCLOAK] Invalid nonce, clearing token');
this.clearToken();
throw new Error('invalid nonce, token cleared');
}
if (fulfillPromise) {
this.onAuthSuccess && this.onAuthSuccess();
}
}
/**
* @private Undocumented.
*/
parseCallback(url) {
const oauthParsed = this.parseCallbackUrl(url);
if (!oauthParsed) {
throw new Error('Failed to parse redirect URL');
}
const oauthState = this.callbackStorage.get(oauthParsed.state);
if (oauthState) {
return { ...oauthParsed,
valid: true,
redirectUri: oauthState.redirectUri,
storedNonce: oauthState.nonce,
prompt: oauthState.prompt,
pkceCodeVerifier: oauthState.pkceCodeVerifier
};
}
return oauthParsed;
}
async processInit(initOptions) {
if (initOptions) {
if (initOptions.token && initOptions.refreshToken) {
var _initOptions$idToken;
this.setToken(initOptions.token, initOptions.refreshToken, (_initOptions$idToken = initOptions.idToken) !== null && _initOptions$idToken !== void 0 ? _initOptions$idToken : null);
try {
await this.updateToken(-1); // Notify onAuthSuccess event handler if set
this.onAuthSuccess && this.onAuthSuccess();
} catch (error) {
// Notify onAuthError event handler if set
this.onAuthError && this.onAuthError({
error,
error_description: 'Failed to refresh token during init'
});
if (initOptions.onLoad) {
this.onLoad(initOptions);
} else {
throw new Error('Failed to init');
}
} // }
} else if (initOptions.onLoad) {
this.onLoad(initOptions);
}
}
}
async onLoad(initOptions) {
switch (initOptions.onLoad) {
case 'login-required':
this.doLogin(initOptions, true);
break;
case 'check-sso':
break;
default:
throw new Error('Invalid value for onLoad');
}
}
async doLogin(initOptions, prompt) {
return this.login({ ...initOptions,
prompt: !prompt ? 'none' : undefined
});
}
setToken(token, refreshToken, idToken, timeLocal) {
if (this.tokenTimeoutHandle) {
clearTimeout(this.tokenTimeoutHandle);
this.tokenTimeoutHandle = null;
}
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);
if (!this.tokenParsed) {
throw new Error('Invalid tokenParsed');
}
this.authenticated = true;
this.subject = this.tokenParsed.sub;
this.realmAccess = this.tokenParsed.realm_access;
this.resourceAccess = this.tokenParsed.resource_access;
if (timeLocal) {
var _this$tokenParsed$iat;
this.timeSkew = Math.floor(timeLocal / 1000) - ((_this$tokenParsed$iat = this.tokenParsed.iat) !== null && _this$tokenParsed$iat !== void 0 ? _this$tokenParsed$iat : 0);
}
if (this.timeSkew != null) {
this.logInfo(`[KEYCLOAK] Estimated time difference between browser and server is ${this.timeSkew} seconds`);
if (this.onTokenExpired) {
var _this$tokenParsed$exp2;
const expiresIn = (((_this$tokenParsed$exp2 = this.tokenParsed.exp) !== null && _this$tokenParsed$exp2 !== void 0 ? _this$tokenParsed$exp2 : 0) - 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 = setTimeout(this.onTokenExpired, expiresIn);
}
}
}
} else {
delete this.token;
delete this.tokenParsed;
delete this.subject;
delete this.realmAccess;
delete this.resourceAccess;
this.authenticated = false;
}
}
createLogger(fn) {
return () => {
if (this.enableLogging) {
fn.apply(console, Array.prototype.slice.call(arguments));
}
};
}
async loadConfig(config) {
let configUrl;
if (!config) {
configUrl = 'keycloak.json';
} else if (typeof config === 'string') {
configUrl = config;
}
if (configUrl) {
const configJSON = await this.adapter.fetchKeycloakConfigJSON(configUrl);
this.realm = configJSON.realm;
this.clientId = configJSON.resource;
this.endpoints = setupOidcEndoints({
realm: this.realm,
authServerUrl: this.authServerUrl
});
return;
}
if (!isKeycloakConfig(config)) {
throw new Error('invalid configuration format');
}
if (!config.clientId) {
throw new Error('clientId missing from configuration');
}
this.clientId = config.clientId;
const oidcProvider = config.oidcProvider; // When oidcProvider config is not supplied, use local configuration params
if (!oidcProvider) {
if (!config.realm) {
throw new Error('realm missing from configuration');
}
this.realm = config.realm;
this.authServerUrl = config.url;
this.endpoints = setupOidcEndoints({
realm: this.realm,
authServerUrl: this.authServerUrl
});
return;
} // When oidcProvider config is a string, load the config from the URL
if (typeof oidcProvider === 'string') {
let oidcProviderConfigUrl;
if (oidcProvider.charAt(oidcProvider.length - 1) === '/') {
oidcProviderConfigUrl = oidcProvider + '.well-known/openid-configuration';
} else {
oidcProviderConfigUrl = oidcProvider + '/.well-known/openid-configuration';
}
try {
const oidcProviderConfig = await this.adapter.fetchOIDCProviderConfigJSON(oidcProviderConfigUrl);
this.endpoints = setupOidcEndoints({
oidcConfiguration: oidcProviderConfig
});
return;
} catch (err) {
throw err;
}
} // Otherwise oidcProvider is a config object and should be used
this.endpoints = setupOidcEndoints({
oidcConfiguration: oidcProvider
});
}
parseCallbackUrl(url) {
let supportedParams = [];
switch (this.flow) {
case 'standard':
supportedParams = ['code', 'state', 'session_state', 'kc_action_status'];
break;
case 'implicit':
supportedParams = ['access_token', 'token_type', 'id_token', 'state', 'session_state', 'expires_in', 'kc_action_status'];
break;
case 'hybrid':
supportedParams = ['access_token', 'id_token', 'code', 'state', 'session_state', 'kc_action_status'];
break;
}
supportedParams.push('error');
supportedParams.push('error_description');
supportedParams.push('error_uri');
const queryIndex = url.indexOf('?');
const fragmentIndex = url.indexOf('#');
let newUrl;
let parsed;
if (this.responseMode === 'query' && queryIndex !== -1) {
newUrl = url.substring(0, queryIndex);
parsed = parseCallbackParams(url.substring(queryIndex + 1, fragmentIndex !== -1 ? fragmentIndex : url.length), supportedParams);
if (parsed.paramsString !== '') {
newUrl += '?' + parsed.paramsString;
}
if (fragmentIndex !== -1) {
newUrl += url.substring(fragmentIndex);
}
} else if (this.responseMode === 'fragment' && fragmentIndex !== -1) {
newUrl = url.substring(0, fragmentIndex);
parsed = parseCallbackParams(url.substring(fragmentIndex + 1), supportedParams);
if (parsed.paramsString !== '') {
newUrl += '#' + parsed.paramsString;
}
}
if (parsed && 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;
}
}
}
return {};
}
}
//# sourceMappingURL=client.js.map