Skip to content
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 #10174

Open
luchsamapparat opened this issue Feb 12, 2022 · 6 comments
Assignees
Labels
Milestone

Comments

@luchsamapparat
Copy link

luchsamapparat commented Feb 12, 2022

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 use keycloak-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:

// processes the parameters from a callback URL.
processCallback(oauth, promise)

// extracts the OAuth parameters from the given callback URL.
parseCallback(url)

// creates a Keycloak-specific Promise wrapper.
createPromise()

Discussion

#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:

  1. Keeping a local, patched copy of 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 adapter
  2. Duplicate a quarter of keycloak-js (see Details below) as part of the custom adapter implementation
  3. Use another third party OAuth library

Alternatives 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
import { App, URLOpenListenerEvent } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
import { isAndroid } from './platform-utils';
import { KeycloakPromiseWrapper, KeycloakAdapter, KeycloakInstance, KeycloakLoginOptions, KeycloakLogoutOptions, KeycloakPromise, KeycloakRegisterOptions } from 'keycloak-js';

export class CapacitorAdapter implements KeycloakAdapter {

  constructor(
    private keycloak: KeycloakInstance
  ) { }

  login(options?: KeycloakLoginOptions): KeycloakPromise<void, void> {
    const loginUrl = this.keycloak.createLoginUrl(options);

    return this.openAndWaitForCallback(
      loginUrl,
      ({ url }, promise) => this.keycloak.processCallback( // ⚠️ method  not accessible
        this.keycloak.parseCallback(url), // ⚠️ method  not accessible
        promise
      )
    );
  }

  logout(options?: KeycloakLogoutOptions): KeycloakPromise<void, void> {
    const logoutUrl = this.keycloak.createLogoutUrl(options);

    return this.openAndWaitForCallback(
      logoutUrl,
      (_, promise) => {
        this.keycloak.clearToken();
        // wait for in-app browser to be completely closed before continuing
        setTimeout(() => promise.setSuccess(), 500);
      }
    );
  }

  register(options?: KeycloakRegisterOptions): KeycloakPromise<void, void> {
    const registerUrl = this.keycloak.createRegisterUrl(options);

    return this.openAndWaitForCallback(
      registerUrl,
      (data, promise) => this.keycloak.processCallback( // ⚠️ method  not accessible
        this.keycloak.parseCallback(data.url), // ⚠️ method  not accessible
        promise
      )
    );
  }

  accountManagement(): KeycloakPromise<void, void> {
    const promise = this.keycloak.createPromise(); // ⚠️ method  not accessible
    const accountUrl = this.keycloak.createAccountUrl();

    if (typeof accountUrl !== 'undefined') {
      promise.setError();
      return promise.promise;
    }

    Browser.open({ url: accountUrl })
      .then(() => promise.setSuccess());

    return promise.promise;
  }

  redirectUri(options: { redirectUri: string; }, encodeHash = true): string {
    if (options && options.redirectUri) {
      return options.redirectUri;
    } else if (this.keycloak.redirectUri) {
      return this.keycloak.redirectUri;
    } else {
      return 'http://localhost';
    }
  }

  private openAndWaitForCallback(url: string, callback: (event: URLOpenListenerEvent, promise: KeycloakPromiseWrapper<void, void>) => void) {
    const promise = this.keycloak.createPromise(); // ⚠️ method  not accessible

    // workaround to use async/await even though we need to return a KeycloakPromise
    (async () => {
      const appListener = await App.addListener('appUrlOpen', async (event) => {
        if (!isAndroid()) {
          await Browser.close();
        }
        await appListener.remove();
        callback(event, promise);
      });

      const open = async () => {
        await Browser.open({ url });
      };

      setTimeout(() => open(), 300);
    })();

    return promise.promise;
  }
}

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 and createPromise, which are not part of the public API of keycloak-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 the KeycloakInstance 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 use keycloak-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();

const req = new XMLHttpRequest();
req.open('POST', url, true);
req.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');

params += '&client_id=' + encodeURIComponent(kc.clientId as string);
params += '&redirect_uri=' + oauth.redirectUri;

if (oauth.pkceCodeVerifier) {
  params += '&code_verifier=' + oauth.pkceCodeVerifier;
}

req.withCredentials = true;

req.onreadystatechange = function () {
  if (req.readyState == 4) {
    if (req.status == 200) {

      const tokenResponse = JSON.parse(req.responseText);
      authSuccess(tokenResponse['access_token'], tokenResponse['refresh_token'], tokenResponse['id_token'], kc.flow === 'standard');
      scheduleCheckIframe.apply(kc);
    } else {
      // @ts-ignore
      kc.onAuthError && kc.onAuthError();
      // @ts-ignore
      promise && promise.setError();
    }
  }
};

req.send(params);

}

function authSuccess(accessToken: string | undefined, refreshToken: string | undefined, idToken: string | undefined, fulfillPromise: unknown) {
timeLocal = (timeLocal + new Date().getTime()) / 2;

setToken.call(kc, accessToken, refreshToken, idToken, timeLocal);

if (useNonce && ((kc.tokenParsed && kc.tokenParsed.nonce != oauth.storedNonce) ||
  (kc.refreshTokenParsed && kc.refreshTokenParsed.nonce != oauth.storedNonce) ||
  (kc.idTokenParsed && kc.idTokenParsed.nonce != oauth.storedNonce))) {

  console.log('[KEYCLOAK] Invalid nonce, clearing token');
  kc.clearToken();
  // @ts-ignore
  promise && promise.setError();
} else {
  if (fulfillPromise) {
    kc.onAuthSuccess && kc.onAuthSuccess();
    promise && promise.setSuccess();
  }
}

}
}

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;

if (timeLocal) {
  kc.timeSkew = Math.floor(timeLocal / 1000) - kc.tokenParsed.iat!;
}

if (kc.timeSkew != null) {
  console.log('[KEYCLOAK] Estimated time difference between browser and server is ' + kc.timeSkew + ' seconds');

  if (kc.onTokenExpired) {
    var expiresIn = (kc.tokenParsed['exp']! - (new Date().getTime() / 1000) + kc.timeSkew) * 1000;
    console.log('[KEYCLOAK] Token expires in ' + Math.round(expiresIn / 1000) + ' s');
    if (expiresIn <= 0) {
      kc.onTokenExpired();
    } else {
      kc.tokenTimeoutHandle = setTimeout(kc.onTokenExpired, expiresIn);
    }
  }
}

} else {
delete kc.token;
delete kc.tokenParsed;
delete kc.subject;
delete kc.realmAccess;
delete kc.resourceAccess;

kc.authenticated = false;

}
}

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;
}

var key = 'kc-callback-' + state;
var value = localStorage.getItem(key);
if (value) {
  localStorage.removeItem(key);
  value = JSON.parse(value);
}

clearExpired();
return value;

};

cs.add = function (state: { state: string, expires: unknown }) {
clearExpired();

var key = 'kc-callback-' + state.state;
state.expires = new Date().getTime() + (60 * 60 * 1000);
localStorage.setItem(key, JSON.stringify(state));

};
};

var CookieStorage: any = function (this: any) {
if (!(this instanceof CookieStorage)) {
return new CookieStorage();
}

var cs = this;

cs.get = function (state: string) {
if (!state) {
return;
}

var value = getCookie('kc-callback-' + state);
setCookie('kc-callback-' + state, '', cookieExpiration(-100));
if (value) {
  return JSON.parse(value);
}

};

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);
},

setError: function (result: unknown) {
  p.reject(result);
}

};
p.promise = new Promise(function (resolve, reject) {
p.resolve = resolve;
p.reject = reject;
});

p.promise.success = function (callback: (...args: unknown[]) => void) {
logPromiseDeprecation();

this.then(function handleSuccess(value: unknown) {
  callback(value);
});

return this;

};

p.promise.error = function (callback: (...args: unknown[]) => void) {
logPromiseDeprecation();

this.catch(function handleError(error: unknown) {
  callback(error);
});

return this;

};

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.');
}
}

</details>
@vaccarov
Copy link

vaccarov commented Apr 7, 2022

Is there any plan on this issue ?
There is currently no solution for using keycloak with Ionic/Capacitor 3 (released a year ago by the time of this post), other than recreating all the adapter's logic, which for most is a deal breaker.

The best solution would be that keycloak-js natively implements capacitor adapter.
2nd solution is to make theses above methods public to let people extends the adaptor as mentioned in the post by @luchsamapparat.

@nseb

This comment was marked as off-topic.

@jy95
Copy link

jy95 commented Sep 23, 2022

@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

@javialon26

This comment was marked as off-topic.

@javialon26
Copy link

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.

@meveno
Copy link

meveno commented Feb 8, 2024

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).
Source

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 :

async function getToken(receivedUrl: string) {
  console.debug(`receivedUrl: ${receivedUrl}`);
  // Example received url: keycloak/#state=39dfb7d9-d852-48c7-9d70-c68c8f329f94&session_state=e31f41f7-ed60-4f50-95a5-5d97c3d97d5b&iss=https%3A%2F%2Fkeycloak.company.org%2Frealms%2Fmyrealm&code=653c0b53-7885-43e2-ab2c-15fb12d01224.e31f41f7-ed60-4f50-95a5-5d97c3d97d5b.a8500fbb-cd9a-4630-a3f6-0897cb3e1a37

  const parsedURl = new URL('https://fake.url/' + receivedUrl.replace('#', '?'));
  const code = parsedURl.searchParams.get('code');

  console.debug(`Received keycloak code = ${code}`);

  const tokenUrl = keycloak.authServerUrl! + '/realms/' + localConfig.KEYCLOAK_REALM + '/protocol/openid-connect/token';
  console.debug(`Getting token from url=${tokenUrl}`);

  const response = await axios.post(
    keycloak.authServerUrl!,
    {
      code: code,
      grant_type: 'authorization_code',
      client_id: localConfig.KEYCLOAK_CLIENT_ID,
      redirect_uri: localConfig.KEYCLOAK_REDIRECT_URI,
    },
    {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );
  console.debug('response', response);
  
  ///// ####### What to do HERE ?
  // keycloak.getToken(code);
  // keycloak.setToken();
}

let promiseResolve!: (value: void) => void;
let promiseReject!: (value: void) => void;
const customKeycloakPromise = new Promise<void>((resolve, reject) => {
  promiseResolve = resolve;
  promiseReject = reject;
});

const MyCustomAdapter: KeycloakAdapter = {
  login(options?: KeycloakLoginOptions) {
    shell.openExternal(keycloak.createLoginUrl(options)); // Open a new external browser window
    ipcRenderer.on('auth-success', (_event, url: string) => { // Manage to get the oauth2 url back
      getToken(url);
      promiseResolve();
    });
    ipcRenderer.on('auth-error', () => promiseReject());
    return customKeycloakPromise;
  },
  // ...
}

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
Manuel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants