Skip to content

Commit

Permalink
m-ld/sustainable-web-apps#7: Using KeyCloak for application login
Browse files Browse the repository at this point in the history
Note: requires m-ld/m-ld-gateway#10
  • Loading branch information
gsvarovsky committed Nov 5, 2023
1 parent faa04a5 commit ee1799e
Show file tree
Hide file tree
Showing 10 changed files with 3,575 additions and 26 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
.DS_Store
.idea
.vercel
.env*.local
60 changes: 60 additions & 0 deletions api/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {createRemoteJWKSet, jwtVerify} from "jose";
import request from "needle";

const {AUTH_SERVER_URL, AUTH_REALM, GATEWAY_ACCOUNT_URL, GATEWAY_KEY} = process.env;
// Credit: https://github.com/keycloak/keycloak-nodejs-connect/issues/492#issuecomment-1603213677
const jwks = createRemoteJWKSet(new URL(
`${AUTH_SERVER_URL}/realms/${AUTH_REALM}/protocol/openid-connect/certs`));
const GATEWAY = new class {
accountUrl = new URL(GATEWAY_ACCOUNT_URL);
accountName = this.accountUrl.pathname.slice(1);
};

// noinspection JSUnusedGlobalSymbols
/**
* @param {import('@vercel/node').VercelRequest} req
* @param {import('@vercel/node').VercelResponse} res
*/
export default async function (req, res) {
const sendErr = (code, text, error) => {
console.warn(error);
res.status(code).json({text, error});
};
const throwErr = (code, text) => error => {
throw {send: () => sendErr(code, text, error)};
};
try {
const subdomain = req.query.domain;
const [type, token] = req.headers.authorization?.split(" ") ?? [];
if (type !== "Bearer")
return sendErr(401, 'Unauthorised');
const {payload} = await jwtVerify(token, jwks)
.catch(throwErr(401, 'Unauthorised'));
await checkAccess(payload.sub, subdomain)
.catch(throwErr(403, 'Forbidden'));
const configRes = await request('put', new URL(
`/api/v1/domain/${GATEWAY.accountName}/${subdomain}`, GATEWAY.accountUrl
).toString(), {
user: {'@id': `${AUTH_SERVER_URL}/users/${payload.sub}`}
}, {
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(configRes.body);
} catch (error) {
error.send ? error.send() : sendErr(500, 'Internal Server Error', error);
}
}

/**
* Check whether the given user has access to the given todolist (domain)
* @param {string} user the authenticated user according to the identity provider
* @param {string} subdomain the todolist identity
*/
async function checkAccess(user, subdomain) {
// TODO: Check fine-grained access to todolists, via sharing
}
86 changes: 86 additions & 0 deletions img/login.seq.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
<title>Template • TodoMVC</title>
<!-- TodoMVC Boilerplate CSS files -->
<link rel="stylesheet" href="css/index.css" />
<script src="https://app.please-open.it/auth/js/keycloak.js"></script>
</head>
<body>
<section class="todoapp">
<header class="header">
<h1><a href=".">todos</a></h1>
<h1><a href=".#//">todos</a></h1>
<input
placeholder="What needs to be done?"
autofocus
Expand Down
52 changes: 42 additions & 10 deletions js/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {delegate, getURLHash, insertHTML, replaceHTML} from "./helpers.js";
import {
delegate,
getAppLocation,
setAppLocation,
insertHTML,
replaceHTML,
setWindowURLFragment
} from "./helpers.js";
import {TodoStore} from "./store.js";
import {uuid} from 'https://edge.js.m-ld.org/ext/index.mjs';

Expand All @@ -20,7 +27,7 @@ const App = {
},
updateFilterHashes() {
App.$.filters.forEach((el) => {
const {filter} = getURLHash(el.getAttribute('href'));
const {filter} = getAppLocation(el.getAttribute('href'));
el.setAttribute('href', `#/${App.todos.id}/${filter}`);
});
},
Expand Down Expand Up @@ -77,18 +84,41 @@ const App = {
}
}
},
init() {
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: 'app-local'
});
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;
},
async init() {
function onHashChange() {
let {documentId, filter} = getURLHash(document.location.hash);
const isNew = !documentId;
if (isNew) {
let {documentId, filter} = getAppLocation() ?? {};
if (!documentId) {
documentId = uuid();
history.pushState(null, null, `#/${documentId}/${filter}`);
setAppLocation(documentId, filter);
}
App.filter = filter;
if (App.todos == null || App.todos.id !== documentId) {
App.todos?.close();
App.todos = new TodoStore(documentId, isNew);
App.todos = new TodoStore(documentId, token);
App.$.updateFilterHashes();
App.todos.addEventListener("save", App.render);
App.todos.addEventListener("error", App.error);
Expand All @@ -97,11 +127,13 @@ const App = {
App.render();
}
}

let token = await App.login();
window.addEventListener("hashchange", onHashChange);
onHashChange();
App.$.input.addEventListener("keyup", (e) => {
if (e.key === "Enter" && e.target.value.length) {
App.todos.add({ title: e.target.value });
App.todos.add({title: e.target.value});
App.$.input.value = "";
}
});
Expand Down Expand Up @@ -185,4 +217,4 @@ const App = {
}
};

App.init();
App.init().catch(error => App.error(new ErrorEvent('error', {error})));
19 changes: 15 additions & 4 deletions js/helpers.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
export const getURLHash = (hash) => {
const [documentId, filter] = hash.split('/').slice(1); // Remove hash symbol
return {documentId: documentId ?? '', filter: filter ?? ''};
};
export function getAppLocation(hash = document.location.hash) {
// Document hash may be the document ID/filter, a login redirect, or nothing
let match = hash.match(/^#\/(\w*)\/(\w*)$/);
if (match)
return {documentId: match[1], filter: match[2]};
}

export function setWindowURLFragment(url) {
// If `url` is null, remove the fragment by setting the URL to the relative pathname
history.pushState(null, null, url ?? window.location.pathname);
}

export function setAppLocation(documentId, filter) {
setWindowURLFragment(`#/${documentId ?? ''}/${filter ?? ''}`);
}

export const delegate = (el, selector, event, handler) => {
el.addEventListener(event, (e) => {
Expand Down
20 changes: 10 additions & 10 deletions js/store.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {clone, isReference, updateSubject, uuid} from 'https://edge.js.m-ld.org/ext/index.mjs';
import {uuid, clone, isReference, updateSubject} from 'https://edge.js.m-ld.org/ext/index.mjs';
import {MemoryLevel} from 'https://edge.js.m-ld.org/ext/memory-level.mjs';
import {IoRemotes} from 'https://edge.js.m-ld.org/ext/socket.io.mjs';

Expand All @@ -11,10 +11,10 @@ import {IoRemotes} from 'https://edge.js.m-ld.org/ext/socket.io.mjs';

/** Store API for current Todos */
export class TodoStore extends EventTarget {
constructor(todosId, isNew) {
constructor(todosId, token) {
super();
this.id = todosId;
this._readStorage(isNew);
this._readStorage(token);
// GETTER methods
this.get = (id) => this.todos.find((todo) => todo['@id'] === id);
this.isAllCompleted = () => this.todos.every((todo) => todo.completed);
Expand All @@ -29,7 +29,7 @@ export class TodoStore extends EventTarget {
_handleError = (error) => {
this.dispatchEvent(new ErrorEvent('error', {error}));
};
_readStorage(isNew) {
_readStorage(token) {
this.todos = [];
// This loads any new to-do details from plain references
// TODO: This will improve with the use of a reactive observable query
Expand All @@ -39,12 +39,12 @@ export class TodoStore extends EventTarget {
todos[i] = await state.get(todo['@id']);
}));
}
clone(new MemoryLevel, IoRemotes, {
'@id': uuid(),
'@domain': `${this.id}.public.gw.m-ld.org`,
genesis: isNew,
io: {uri: `https://gw.m-ld.org`}
}).then(async meld => {
// Exchange the access token for gateway domain configuration
fetch(`/api/config?domain=${this.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
}).then(async configRes => {
const config = await configRes.json();
const meld = await clone(new MemoryLevel, IoRemotes, {'@id': uuid(), ...config});
this.meld = meld;
await meld.status.becomes({ outdated: false });
meld.read(async state => {
Expand Down
38 changes: 38 additions & 0 deletions login.seq.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
@startuml
'https://plantuml.com/sequence-diagram

autonumber

participant "App\nClient" as appC
participant "Identity\nProvider" as idp
participant "App\nLambda" as appL
participant "**m-ld**\nGateway" as gw

activate appC
alt not logged in
appC -> idp ++ : login
return appToken
alt new device
appC -> appC : generate device key
end
end

appC -> appL ++ : get GW token (appToken, publicKey, domainName)
appL -> appL : validate appToken
appL -> appL : check access
appL -> gw ++ : get config\n(publicKey, domainName)
note right: also ensures user is\nin domain, if required
return config
return config

appC -> gw : connect to domain (config)
deactivate appC

loop
appC -> appC : sign operation (privateKey)
appC --> gw : operation //via domain remotes//
gw -> gw : validate operation (publicKey)
gw -> gw : log operation
end

@enduml
Loading

0 comments on commit ee1799e

Please sign in to comment.