-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Expose processCallback
, parseCallback
and createPromise
to custom adapters for keycloak-js
#27
Comments
Is there any plan on this issue ? The best solution would be that keycloak-js natively implements capacitor adapter. |
This comment was marked as off-topic.
This comment was marked as off-topic.
@nseb @luchsamapparat I have a fork ( https://github.com/jy95/keycloak-capacitor ) that mostly automates the addition of Capacitor adapters. Better that nothing ;) Example of auto-generated release : jy95/keycloak-capacitor#10 |
This comment was marked as off-topic.
This comment was marked as off-topic.
I created a PR with a possible solution with minimal changes. I'm using this with a custom adapter for Capacitor/Capacitor Broser and is working like a charm. |
Hi I'm trying to develop a custom adapter in my electron app that uses default browser so we could use an OTP physical key (not actually supported by electron). I'm trying to do something like the existing cordova adapter (but this one is internal) : Here I've reached to open a new external browser window, handled the login process but I can't finalyze the process and give the keycloak code (or token) to the keycloakjs instance. Here is an extract of my actual code :
Can anyone help me ? 2nd question, would it be possible to have a keycloak.createTokenUrl() method to avoid the ugly concatenation (or have keycloak.endpoints exposed) ... Regards |
Description
Writing a custom adapter for Capacitor (which can be seen as the spiritual successor of Cordova), as suggested by the documentation, is not possible without recreating a lot of logic implemented by methods which are not part of the public API of
keycloak-js
.This logic includes parsing and processing callbacks and creating instances of the
keycloak-js
-specific implementation of promises. Not having to deal with parsing and processing of callbacks is one if not the main reason to usekeycloak-js
.The enhancement in this issue is therefore to expose three additional methods, which are already used by the builtin adapters. This would make it possible to write a custom adapter for Capacitor the same way, as the builtin adapters for Cordova are implemented:
Discussion
keycloak/keycloak#8623
Motivation
As the Keycloak documentation confirms, writing a custom adapter is the preferred way to use
keycloak-js
in a Capacitor-based mobile app environment. But using the builtin Cordova adapter as a blueprint, you realize that it makes use of the mentioned private methods which cannot be used by custom adapters.Exposing these methods means, that a custom adapter has the same capabilities as builtin adapters and can reuse the authentication logic of
keycloak-js
which is the main reason to use it in the first place.As long as these methods are not made public, there are only three ways to integrate Keycloak with Capacitor:
keycloak-js
or using one provided by a third party (e.g. keycloak-ionic), which either expose those methods or add Capactior as a builtin adapterkeycloak-js
(see Details below) as part of the custom adapter implementationAlternatives 1 and 2 have the downside that the solutions have to be kept up-to-date with the original
keycloak-js
library.Alternative 3 may be viable, but there are a lot of nice features in
keycloak-js
which I'd miss. Also there's the "one-stop shop" argument: If I use Keycloak, I'd also like to use the official Keycloak JS client and not some other library which may not integrate as well with Keycloak.Details
This is what a custom adapter for Capacitor might look like (written in TypeScript):
Implementation of CapacitorAdapter
As one would expect, it is mainly concerned with what's specific to integrating Capacitor with
keycloak-js
.However, it does not work as it uses
processCallback
,parseCallback
andcreatePromise
, which are not part of the public API ofkeycloak-js
(see⚠️ method not accessible
comments).An additional 470 lines of code are required, to re-implement the logic provided by these methods. And even then, these methods access private variables within
keycloak-js
(e.g.loginIframe
), which means that there may be state within theKeycloakInstance
that does not match the state within the custom adapter implementation. So in the end, there may be even more duplicated logic necessary, to make it really work. At that point, there's probably no reason to usekeycloak-js
at all...Code necessary for reimplementing processCallback, parseCallback and createPromise
```js function processCallback(this: KeycloakInstance & { endpoints: KeycloakInstanceEndpoints, tokenTimeoutHandle: ReturnType }, oauth: Oauth, promise: KeycloakPromiseWrapper) { const kc = this; const code = oauth.code; const error = oauth.error; const prompt = oauth.prompt;let timeLocal = new Date().getTime();
if (oauth['kc_action_status']) {
kc.onActionUpdate && kc.onActionUpdate(oauth['kc_action_status']);
}
if (error) {
if (prompt != 'none') {
const errorData = { error: error, error_description: oauth.error_description };
kc.onAuthError && kc.onAuthError(errorData);
promise && promise.setError(errorData);
} else {
promise && promise.setSuccess();
}
return;
} else if ((kc.flow != 'standard') && (oauth.access_token || oauth.id_token)) {
authSuccess(oauth.access_token, undefined, oauth.id_token, true);
}
if ((kc.flow != 'implicit') && code) {
let params = 'code=' + code + '&grant_type=authorization_code';
const url = kc.endpoints.token();
}
function authSuccess(accessToken: string | undefined, refreshToken: string | undefined, idToken: string | undefined, fulfillPromise: unknown) {
timeLocal = (timeLocal + new Date().getTime()) / 2;
}
}
function parseCallback(this: KeycloakInstance, url: string) {
const kc = this;
var oauth = parseCallbackUrl.call(kc, url);
if (!oauth) {
return;
}
var oauthState = 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;
}
return oauth;
}
function parseCallbackUrl(this: KeycloakInstance, url: string) {
const kc = this;
var supportedParams: string[] = [];
switch (kc.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', 'token_type', 'id_token', 'code', 'state', 'session_state', 'expires_in', 'kc_action_status'];
break;
}
supportedParams.push('error');
supportedParams.push('error_description');
supportedParams.push('error_uri');
var queryIndex = url.indexOf('?');
var fragmentIndex = url.indexOf('#');
var newUrl;
var parsed;
if (kc.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 (kc.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 (kc.flow === 'standard' || kc.flow === 'hybrid') {
if ((parsed.oauthParams.code || parsed.oauthParams.error) && parsed.oauthParams.state) {
parsed.oauthParams.newUrl = newUrl;
return parsed.oauthParams;
}
} else if (kc.flow === 'implicit') {
if ((parsed.oauthParams.access_token || parsed.oauthParams.error) && parsed.oauthParams.state) {
parsed.oauthParams.newUrl = newUrl;
return parsed.oauthParams;
}
}
}
}
function parseCallbackParams(paramsString: string, supportedParams: string[]) {
var p = paramsString.split('&');
var result = {
paramsString: '',
oauthParams: {} as any
};
for (var i = 0; i < p.length; i++) {
var split = p[i].indexOf('=');
var key = p[i].slice(0, split);
if (supportedParams.indexOf(key) !== -1) {
result.oauthParams[key] = p[i].slice(split + 1);
} else {
if (result.paramsString !== '') {
result.paramsString += '&';
}
result.paramsString += p[i];
}
}
return result;
}
function setToken(this: KeycloakInstance & { tokenTimeoutHandle: ReturnType | null }, token: string | undefined, refreshToken: string | undefined, idToken: string | undefined, timeLocal: number) {
const kc = this;
if (kc.tokenTimeoutHandle) {
clearTimeout(kc.tokenTimeoutHandle);
kc.tokenTimeoutHandle = null;
}
if (refreshToken) {
kc.refreshToken = refreshToken;
kc.refreshTokenParsed = decodeToken(refreshToken);
} else {
delete kc.refreshToken;
delete kc.refreshTokenParsed;
}
if (idToken) {
kc.idToken = idToken;
kc.idTokenParsed = decodeToken(idToken);
} else {
delete kc.idToken;
delete kc.idTokenParsed;
}
if (token) {
kc.token = token;
kc.tokenParsed = decodeToken(token);
kc.sessionId = kc.tokenParsed.session_state;
kc.authenticated = true;
kc.subject = kc.tokenParsed.sub;
kc.realmAccess = kc.tokenParsed.realm_access;
kc.resourceAccess = kc.tokenParsed.resource_access;
} else {
delete kc.token;
delete kc.tokenParsed;
delete kc.subject;
delete kc.realmAccess;
delete kc.resourceAccess;
}
}
function decodeToken(str: string) {
str = str.split('.')[1];
str = str.replace(/-/g, '+');
str = str.replace(/_/g, '/');
switch (str.length % 4) {
case 0:
break;
case 2:
str += '==';
break;
case 3:
str += '=';
break;
default:
throw 'Invalid token';
}
str = decodeURIComponent(escape(atob(str)));
return JSON.parse(str) as Keycloak.KeycloakTokenParsed;
}
const loginIframe = {
enable: true,
callbackList: [] as unknown[],
interval: 5,
iframe: document.createElement('iframe'),
iframeOrigin: undefined
};
function scheduleCheckIframe(this: KeycloakInstance) {
const kc = this;
if (loginIframe.enable) {
if (this.token) {
setTimeout(function () {
checkLoginIframe.apply(kc).then(function (unchanged: unknown) {
if (unchanged) {
scheduleCheckIframe.apply(kc);
}
});
}, loginIframe.interval * 1000);
}
}
}
function checkLoginIframe(this: KeycloakInstance) {
const kc = this;
const promise = createPromise();
if (loginIframe.iframe && loginIframe.iframeOrigin) {
const msg = kc.clientId + ' ' + (kc.sessionId ? kc.sessionId : '');
loginIframe.callbackList.push(promise);
const origin = loginIframe.iframeOrigin;
if (loginIframe.callbackList.length == 1) {
loginIframe.iframe.contentWindow?.postMessage(msg, origin);
}
} else {
promise.setSuccess();
}
return promise.promise;
}
var LocalStorage: any = function (this: any) {
if (!(this instanceof LocalStorage)) {
return new LocalStorage();
}
localStorage.setItem('kc-test', 'test');
localStorage.removeItem('kc-test');
var cs = this;
function clearExpired() {
var time = new Date().getTime();
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i);
if (key && key.indexOf('kc-callback-') == 0) {
var value = localStorage.getItem(key);
if (value) {
try {
var expires = JSON.parse(value).expires;
if (!expires || expires < time) {
localStorage.removeItem(key);
}
} catch (err) {
localStorage.removeItem(key);
}
}
}
}
}
cs.get = function (state: string) {
if (!state) {
return;
}
};
cs.add = function (state: { state: string, expires: unknown }) {
clearExpired();
};
};
var CookieStorage: any = function (this: any) {
if (!(this instanceof CookieStorage)) {
return new CookieStorage();
}
var cs = this;
cs.get = function (state: string) {
if (!state) {
return;
}
};
cs.add = function (state: { state: string }) {
setCookie('kc-callback-' + state.state, JSON.stringify(state), cookieExpiration(60));
};
cs.removeItem = function (key: string) {
setCookie(key, '', cookieExpiration(-100));
};
var cookieExpiration = function (minutes: number) {
var exp = new Date();
exp.setTime(exp.getTime() + (minutes * 60 * 1000));
return exp;
};
var getCookie = function (key: string) {
var name = key + '=';
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return '';
};
var setCookie = function (key: string, value: string, expirationDate: Date) {
var cookie = key + '=' + value + '; '
+ 'expires=' + expirationDate.toUTCString() + '; ';
document.cookie = cookie;
};
};
function createCallbackStorage() {
try {
return new LocalStorage();
} catch (err) {
}
return new CookieStorage();
}
function createPromise() {
// Need to create a native Promise which also preserves the
// interface of the custom promise type previously used by the API
var p: any = {
setSuccess: function (result: unknown) {
p.resolve(result);
},
};
p.promise = new Promise(function (resolve, reject) {
p.resolve = resolve;
p.reject = reject;
});
p.promise.success = function (callback: (...args: unknown[]) => void) {
logPromiseDeprecation();
};
p.promise.error = function (callback: (...args: unknown[]) => void) {
logPromiseDeprecation();
};
return p;
}
var loggedPromiseDeprecation = false;
function logPromiseDeprecation() {
if (!loggedPromiseDeprecation) {
loggedPromiseDeprecation = true;
console.warn('[KEYCLOAK] Usage of legacy style promise methods such as
.error()
and.success()
has been deprecated and support will be removed in future versions. Use standard style promise methods such as.then() and
.catch()` instead.');}
}
The text was updated successfully, but these errors were encountered: