Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Created September 20, 2023 10:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomasdarimont/c31d725d8c11d3136e11f3a99129da7c to your computer and use it in GitHub Desktop.
Save thomasdarimont/c31d725d8c11d3136e11f3a99129da7c to your computer and use it in GitHub Desktop.
Example SPA for Keycloak Demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Acme Mini SPA</title>
<style>
body {
background-color: #eaeaea;
font-family: sans-serif;
font-size: 10px;
}
button {
font-family: sans-serif;
font-size: 25px;
width: 200px;
background-color: #0085cf;
background-image: linear-gradient(to bottom, #00a8e1 0%, #0085cf 100%);
background-repeat: repeat-x;
border: 2px solid #ccc;
color: #fff;
text-transform: uppercase;
-webkit-box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.5);
-moz-box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.5);
box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.5);
}
button:hover {
background-color: #006ba6;
background-image: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
hr {
border: none;
background-color: #eee;
height: 10px;
}
.menu {
padding: 10px;
margin-bottom: 10px;
}
.content {
font-size: 20px;
background-color: #eee;
border: 1px solid #ccc;
padding: 10px;
-webkit-box-shadow: 2px 2px 10px 0 rgba(0, 0, 0, 0.5);
-moz-box-shadow: 2px 2px 10px 0 rgba(0, 0, 0, 0.5);
box-shadow: 2px 2px 10px 0 rgba(0, 0, 0, 0.5);
}
.message-content {
font-size: 20px;
padding: 10px;
background-color: #fff;
border: 1px solid #ccc;
}
.token-content {
font-size: 20px;
padding: 5px;
white-space: pre;
text-transform: none;
}
.wrapper {
position: absolute;
left: 10px;
top: 40px;
bottom: 10px;
right: 10px;
}
.error {
color: #a21e22;
}
table {
width: 100%;
}
table.credentials, table.profile, table.apps {
width: unset;
}
tr.even {
background-color: #eee;
}
td {
padding: 5px;
}
td.label {
font-weight: bold;
width: 250px;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div>
<h1>ClientId: <span id="clientInfo"></span></h1>
</div>
<div id="welcome" class="wrapper hidden">
<div class="menu">
<button name="loginBtn" onclick="keycloak.login()">Login</button>
</div>
<div class="message-content">
<div class="message">Please login</div>
<button name="registerBtn" onclick="keycloak.register()">Register</button>
</div>
</div>
<div id="content" class="wrapper hidden">
<div class="menu">
<button name="profileBtn" onclick="showProfile()" class="profile">Profile</button>
<button name="tokenBtn" onclick="showToken()" class="token">AccessToken</button>
<button name="idTokenBtn" onclick="showIdToken()" class="idToken">IDToken</button>
<button name="userinfoBtn" onclick="showUserInfo()" class="userinfo">Userinfo</button>
<button name="reauthBtn" onclick="enforceCurrentAuth()" class="reauth">ReAuth</button>
<button name="accountBtn" onclick="keycloak.accountManagement()" class="account">Account</button>
<button name="logoutBtn" onclick="keycloak.logout()" class="logout">Logout</button>
<button name="revokeBtn" onclick="revokeToken()" class="revoke">Revoke</button>
</div>
<div id="data" class="content"></div>
</div>
<script defer>
function $(selector) {
return document.querySelector(selector);
}
let searchParams = new URLSearchParams(window.location.search);
let keycloakBaseUrl = searchParams.get("base_url") || (window.location.protocol === "http:" ? "http://id.acme.test:8080" : "https://id.acme.test:8443");
let keycloakUrl = keycloakBaseUrl + (searchParams.get("path") || "/auth");
let realm = searchParams.get("realm") || 'workshop';
let clientId = searchParams.get("client_id") || 'app-minispa';
// ?scope=openid+email+custom.profile+custom.ageinfo
//let scope = searchParams.get("scope") || 'openid email acme.profile acme.ageinfo';
let scope = searchParams.get("scope") || 'openid email';
// &show=profile,logout
// &show=profile,logout,token,idToken,userinfo
// &show=profile,logout,token,idToken,userinfo,reauth
const allContextClasses = ["profile", "token", "idToken", "userinfo", "reauth", "account", "logout", "revoke"];
const contextClassesToHideDefault = ["token", "idToken", "userinfo", "reauth", "revoke"];
const contextClassesToShowDefault = [...allContextClasses].filter((value, index, arr) => {
return !contextClassesToHideDefault.includes(value);
});
let contextClassesToShow = searchParams.get("show")?.split(",") || contextClassesToShowDefault;
for (let className of allContextClasses) {
if (!contextClassesToShow.includes(className)) {
let btn = $(`button.${className}`);
if (btn) {
btn.parentElement.removeChild(btn);
}
}
}
$("#clientInfo").textContent = clientId;
// dynamically add keycloak.js script
let script = document.createElement('script');
script.type = 'text/javascript';
script.src = keycloakUrl + "/js/keycloak.js";
document.getElementsByTagName('head')[0].appendChild(script);
window.onload = () => {
let keycloak = new Keycloak({
url: keycloakUrl,
realm: realm,
clientId: clientId
});
window.keycloak = keycloak;
// workaround for changes with oidc logout in Keycloak 18.0.0
// See https://www.keycloak.org/docs/latest/upgrading/index.html#openid-connect-logout
keycloak.createLogoutUrl = function(options) {
return keycloak.endpoints.logout()
+ '?id_token_hint=' + keycloak.idToken
+ '&post_logout_redirect_uri=' + encodeURIComponent(window.location.href);
}
let initConfig = {
onLoad: 'login-required', // redirects to login if not login
// onLoad: 'check-sso', // shows login and register button if not logged in
checkLoginIframe: true,
checkLoginIframeInterval: 1,
pkceMethod: 'S256',
scope: scope
};
let onLoginSuccess = () => {
if (keycloak.authenticated) {
showProfile();
} else {
showWelcome();
}
};
keycloak.init(initConfig).then(onLoginSuccess);
keycloak.onAuthLogout = showWelcome;
};
async function showProfile() {
await keycloak.updateToken(5);
// use firstName / lastName from IDToken directly
let firstName = escapeHtml(keycloak.tokenParsed.given_name) || "";
let lastName = escapeHtml(keycloak.tokenParsed.family_name) || "";
// Alternatively we could also read the values from the IDToken
// let firstName = escapeHtml(keycloak.idTokenParsed['given_name']);
// let lastName = escapeHtml(keycloak.idTokenParsed['family_name']);
// use email from IDToken directly
let email = escapeHtml(keycloak.idTokenParsed['email']);
let emailVerified = keycloak.idTokenParsed['email_verified'];
if (!email) {
email = "N/A";
emailVerified = false;
}
// use phoneNumber from IDToken directly
let phoneNumber = escapeHtml(keycloak.idTokenParsed['phone_number']);
let phoneNumberVerified = keycloak.idTokenParsed['phone_number_verified']
if (!phoneNumber){
phoneNumber = "N/A";
phoneNumberVerified = false;
}
let picture = escapeHtml(keycloak.idTokenParsed['picture']);
if (!picture) {
// https://png-pixel.com
picture = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
}
let profileHtml = `
<table class="profile">
<tr>
<td class="label">First name</td>
<td><input type="text" id="firstName" name="firstName" value="${firstName}" pattern="[\w\d][\w\d\s]{0,64}" placeholder="Firstname" required></td>
<td></td>
<td></td>
<th rowspan="2"><img src="${picture}"></th>
</tr>
<tr>
<td class="label">Last name</td>
<td><input type="text" id="lastName" name="lastName" value="${lastName}" pattern="[\w\d][\w\d\s]{0,64}" placeholder="Lastname" required></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td class="label">Email</td>
<td><span id="email">${email}</span></td>
<td title="${ emailVerified ? 'Email verified' : ''}">${ emailVerified ? '&#10004;' : ''}</td>
<td><a id="changeEmail" href="#" onclick="changeEmail();return false">Update</a></td>
<td></td>
</tr>
<tr>
<td class="label">Phone</td>
<td><span id="phoneNumber">${phoneNumber}</span></td>
<td title="${ phoneNumberVerified ? 'Phone number verified' : ''}">${ phoneNumberVerified ? '&#10004;' : ''}</td>
<td></td>
<td></td>
</tr>
</table>
<button id="btnSaveProfile" onClick="saveProfile(); return false">Save</button>
<button name="deleteAccountBtn" onclick="requestAccountDeletion()" class="accountDeletion">Delete</button>
<span id="profileStatus"></span>
`;
show(profileHtml, "message-content");
}
function showWelcome() {
document.getElementById("welcome").classList.remove("hidden");
document.getElementById("content").classList.add("hidden");
}
function getTimeSinceLastAuth() {
let timeSinceAuthInSeconds = Math.floor((Date.now() - (keycloak.tokenParsed.auth_time * 1000)) / 1000);
return timeSinceAuthInSeconds;
}
function enforceCurrentAuth() {
let timeSinceAuthSeconds = getTimeSinceLastAuth();
console.log("time since auth: " + timeSinceAuthSeconds);
if (timeSinceAuthSeconds < 10) {
console.log("auth is still file")
return;
} else {
console.log("trigger reauth")
}
keycloak.login({
loginHint: keycloak.tokenParsed.preferred_username,
maxAge: 12
});
}
async function revokeToken() {
const bodyData = new URLSearchParams();
bodyData.append("token", keycloak.refreshToken);
bodyData.append("client_id", clientId);
let response = await sendRequest(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/revoke`, {
method: "POST",
credentials: "include", // send auth cookies
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
body: bodyData
});
console.log(response);
// window.location.reload();
}
function formatDate(timestamp) {
if (!timestamp) {
return "--";
}
return new Intl.DateTimeFormat('de-DE', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp))
}
function changePassword() {
keycloak.login({
action: "UPDATE_PASSWORD"
});
}
function changeEmail() {
keycloak.login({
action: "UPDATE_EMAIL" // use native update email action
});
}
function sendRequest(url, requestOptions) {
let requestData = {
timeout: 2000,
method: "GET",
headers: {
"Authorization": "Bearer " + keycloak.token,
"Accept": "application/json",
'Content-Type': 'application/json'
}
, ...requestOptions
}
return fetch(url, requestData);
}
function showToken() {
let data = JSON.stringify(keycloak.tokenParsed, null, ' ');
show(data, "token-content");
}
function showIdToken() {
let data = JSON.stringify(keycloak.idTokenParsed, null, ' ');
show(data, "token-content");
}
async function showUserInfo() {
await keycloak.updateToken(5);
let userInfoData = await keycloak.loadUserInfo();
let data = JSON.stringify(userInfoData, null, ' ');
show(data, "token-content");
}
function show(data, cssClass) {
let contentElement = $('#content');
contentElement.classList.remove("hidden")
let dataElement = $('#data');
dataElement.innerHTML = data;
dataElement.classList.remove(["message-content", "token-content"]);
dataElement.classList.add(cssClass);
}
// Use the browser's built-in functionality to quickly and safely escape
// the string
function escapeHtml(str) {
if (!str) {
return "";
}
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment