Created
January 17, 2024 10:27
-
-
Save thomasdarimont/ffbf2e352cf6338a3c993dc82e5e1e6f to your computer and use it in GitHub Desktop.
Demo SPA
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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 ? '✔' : ''}</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 ? '✔' : ''}</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