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

Use KeyCloak for application login #4

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft

Conversation

gsvarovsky
Copy link
Member

@gsvarovsky gsvarovsky commented Nov 5, 2023

💡 Note this branch & pull request are intended to be long-lived, as they show modifications to the main branch for a specific feature.


This branch shows how to use an Identity Provider and a keystore for user login and signatures. See the PR change comments for additional information. Note that while these features are combined here, it would be straightforward to use Identity Provider login without also implementing signatures.

identity provider login

This feature is implemented using Gateway JWT authentication tokens, as described in the Gateway documentation.

The chosen Identity Provider is KeyCloak, as an exemplar of an open-source OpenID Connect service. Required configuration items for running the demo with your own KeyCloak instance are given below.

For our deployment at m-ld-todomvc-vanillajs-git-user-accounts-m-ld.vercel.app, we have created a realm with the free online KeyCloak service provided by Please Open It. If you want to try the deployed app, please email info@m-ld.org and we'll register you.

The architecture uses a serverless lambda to exchange KeyCloak tokens for Gateway tokens. This can be found in the /api folder, and works out of the box with Vercel serverless functions. The code can be easily ported to another serverless provider or a web framework like Express.js.

The following configuration items are required for the KeyCloak integration. These can be found:

  • As constructor parameters for the Keycloak client library, in app.js
  • As environment variables required for the serverless lambda
Item Keycloak constructor parameter Lambda environment variable Example
Keycloak URL url AUTH_SERVER_URL https://app.please-open.it/auth
Realm ID realm AUTH_REALM 5d970145-a48d-4e9b-84ab-15de19f9d3a5
Client ID clientId (uses page hostname by default) N/A m-ld-todomvc-vanillajs-git-user-accounts-m-ld.vercel.app
Gateway account N/A GATEWAY_ACCOUNT_URL https://gw.m-ld.org/m-ld-todomvc-vanillajs
Gateway account key N/A GATEWAY_KEY appid.lccfcj:...

The KeyCloak client configuration in this demo is straightforward and as recommended for use with the KeyCloak Javascript adapter. For reference, the configuration for the Client ID given above is attached: m-ld-todomvc-vanillajs-git-user-accounts-m-ld.vercel.app.json.

signatures

This feature is implemented using a client-side library to generate and store RSA public keys for the local browser ("device"), integrated with the Gateway support for digital signatures.

The library is keystore-idp, which wraps the browser's native ability to store unexportable private CryptoKey objects in IndexedDB.

See the comments in Files changed for further information.


Note: requires @m-ld/m-ld-gateway@0.1.0-edge.3

Copy link

vercel bot commented Nov 5, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
m-ld-todomvc-vanillajs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 21, 2023 6:51pm

import {createRemoteJWKSet, jwtVerify} from "jose";
import request from "needle";

const {AUTH_SERVER_URL, AUTH_REALM, GATEWAY_ACCOUNT_URL, GATEWAY_KEY} = process.env;
Copy link
Member Author

@gsvarovsky gsvarovsky Nov 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file implements a serverless lambda which exchanges an Identity Provider (IdP) token for a Gateway token. It requires the environment variables above, as listed in the PR description.

Comment on lines +29 to +33
const [type, token] = req.headers.authorization?.split(" ") ?? [];
if (type !== "Bearer")
return sendErr(401, 'Unauthorised');
const {payload} = await jwtVerify(token, jwks)
.catch(throwErr(401, 'Unauthorised'));
Copy link
Member Author

@gsvarovsky gsvarovsky Nov 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Identity Provider token is provided as a Bearer token in the Authorization HTTP header, and validated using the Identity Provider JWK keyset. Note that this is performing local JWT verification and not normally making a network call to the Identity Provider; but the jose library will periodically update the keyset from the provider.

Comment on lines +36 to +50
const pid = `${AUTH_SERVER_URL}/users/${payload.sub}`;
const configRes = await request('put', new URL(
`/api/v1/domain/${GATEWAY.accountName}/${subdomain}`, GATEWAY.accountUrl
).toString(), {
useSignatures: 'public' in key,
user: {'@id': pid, key}
}, {
json: true,
auth: 'basic',
username: GATEWAY.accountName,
password: GATEWAY_KEY
});
if (configRes.statusCode !== 200)
return sendErr(configRes.statusCode, configRes.statusMessage, configRes.body);
res.status(200).json({config: configRes.body, pid});
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the user's IdP token is validated and user access is checked, the lambda calls the Gateway to obtain the subdomain configuration, which will include a newly-minted JWT for the client. If a public key has been passed as the request body, it is forwarded to the Gateway, which will enter the public key into the subdomain for signature verifications.

Comment on lines +61 to +63
async function checkAccess(user, subdomain) {
// TODO: Check fine-grained access to todolists, via sharing
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this demo, we not performing any user access authorisation checks against particular subdomains, except of course that the user is registered to use this app with the IdP. Such access checks could use additional features of the IdP such as KeyCloak authorisation services.

Comment on lines +88 to +111
async login() {
// Login removes the document fragment, so keep it in storage
// See https://www.rfc-editor.org/rfc/rfc6749#section-3.1.2
if (getAppLocation() != null) {
sessionStorage.setItem("docLocation", document.location.hash);
setWindowURLFragment(null);
}
const keycloak = new Keycloak({
url: "https://app.please-open.it",
realm: "5d970145-a48d-4e9b-84ab-15de19f9d3a5",
clientId: window.location.hostname
});
await keycloak.init({
onLoad: "login-required",
flow: "implicit",
checkLoginIframe: false
});
let docLocation = sessionStorage.getItem("docLocation");
if (docLocation != null) {
sessionStorage.removeItem("docLocation");
setWindowURLFragment(docLocation);
}
return keycloak.token;
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new login method uses the KeyCloak Javascript adapter to login to the IdP. The only complication here is that this app uses the URL fragment for identification of the TodoList (its subdomain name), which clashes with the fragments used when the IdP redirects back to the application. To avoid this problem we use session storage to re-instigate the original fragment after login.

Comment on lines 114 to +119
let {documentId, filter} = getAppLocation() ?? {};
const isNew = !documentId;
if (isNew) {
documentId = uuid();
if (!documentId) {
documentId = (documentId ?? localStorage.getItem("lastTodoList")) || uuid();
setAppLocation(documentId, filter);
}
localStorage.setItem("lastTodoList", documentId);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we change the behaviour of the app to redirect itself back to the user's last TodoList if no URL fragment was specified. This is possible because the use of Named Subdomains in the Gateway means the TodoLists are stored there. This is a significant upgrade to the application: a user can navigate to their stored TodoLists any time on any device, without having to have them open elsewhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new Javascript file wraps up the keystore-idb library (introduced as a global, window.keystore.default, by the script in index.html) as a Device. In particular, a Device can present itself to m-ld as an Initial App with a transport security member for signing messages, as described in the Gateway documentation.

Comment on lines +43 to 56
const device = await Device.here();
// Exchange the access token for gateway domain configuration
const configRes = await fetch(`/api/config?domain=${this.id}`, {
method: 'POST',
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify(device.key)
});
const {config, pid} = await configRes.json();
const meld = await clone(
new MemoryLevel,
IoRemotes,
config
{'@id': uuid(), ...config},
device.asMeldApp(pid)
);
Copy link
Member Author

@gsvarovsky gsvarovsky Nov 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we replace the hard-wired clone configuration with the configuration received from the lambda. No special handling is needed for the Gateway JWT embedded in the configuration, we just pass it all to m-ld, remembering to add the @id for the clone. We also provide the signatures implementation to m-ld via the "device".

@gsvarovsky gsvarovsky changed the title Using KeyCloak for application login Use KeyCloak for application login Dec 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant