377 lines
10 KiB
JavaScript
377 lines
10 KiB
JavaScript
/**
|
|
* D4Science boot component that handles keykloak authz
|
|
* <d4s-boot-2
|
|
* /@url: keycloak url, for example: https://accounts.d4science.org/auth]
|
|
* /@realm: is the realm [defaults to: d4science]
|
|
* /@gateway: is the client id, for example: "pre.d4science.org"
|
|
* /@context: is the audience, for example: "%2Fpred4s%2Fpreprod%2FpreVRE"
|
|
* /@redirect-url: where to go on logout
|
|
*/
|
|
window.customElements.define('d4s-boot-2', class extends HTMLElement {
|
|
|
|
#keycloak = null
|
|
#authorization = null
|
|
#url = null
|
|
#realm = "d4science"
|
|
#clientId = null
|
|
#redirectUrl = null
|
|
#audience = null
|
|
#authenticated = false
|
|
#locked = true
|
|
#queue = []
|
|
#interval = null
|
|
#config = null
|
|
#rpt = null
|
|
|
|
constructor() {
|
|
super()
|
|
}
|
|
|
|
unlock() {
|
|
this.#locked = false
|
|
}
|
|
|
|
fire(etype) {
|
|
const tp = this.#keycloak.tokenParsed
|
|
var outtp = {
|
|
aud: tp.aud,
|
|
azp: tp.azp,
|
|
email: tp.email,
|
|
email_verified: tp.email_verified,
|
|
exp: tp.exp,
|
|
family_name: tp.family_name,
|
|
given_name: tp.given_name,
|
|
locale: tp.locale,
|
|
name: tp.name,
|
|
preferred_username: tp.preferred_username
|
|
}
|
|
const evt = new CustomEvent(etype, { detail : outtp })
|
|
document.dispatchEvent(evt)
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.startStateChecker()
|
|
|
|
this.loadKeycloak().then(() => {
|
|
console.log("Keycloak loaded")
|
|
return this.initKeycloak()
|
|
|
|
}).then(authenticated => {
|
|
if (!authenticated) {
|
|
throw "Failed to authenticate"
|
|
}
|
|
console.log("Keycloak initialized and user authenticated")
|
|
//console.log("Token exp: " + this.expirationDate(this.#keycloak.tokenParsed.exp))
|
|
|
|
//if an audience is provided then perform also authorization
|
|
if (this.#audience) {
|
|
return this.loadConfig()
|
|
} else {
|
|
Promise.resolve()
|
|
}
|
|
|
|
}).then(() => {
|
|
this.#authenticated = true
|
|
this.unlock()
|
|
this.fire("authenticated")
|
|
|
|
}).catch(err => {
|
|
console.error("Unable to initialize Keycloak", err)
|
|
})
|
|
}
|
|
|
|
loadKeycloak() {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof Keycloak === 'undefined') {
|
|
const script = document.createElement('script')
|
|
script.src = this.#url + '/js/keycloak.js'
|
|
script.type = 'text/javascript'
|
|
script.addEventListener('load', resolve)
|
|
document.head.appendChild(script)
|
|
} else {
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
|
|
initKeycloak() {
|
|
this.#keycloak = new Keycloak({
|
|
url: this.#url,
|
|
realm: this.#realm,
|
|
clientId: this.#clientId
|
|
})
|
|
|
|
return this.#keycloak.init({onLoad: 'login-required', checkLoginIframe: false })
|
|
}
|
|
|
|
startStateChecker() {
|
|
this.#interval = window.setInterval(() => {
|
|
if (this.#locked) {
|
|
console.log("Still locked. Currently has " + this.#queue.length + " pending requests.")
|
|
} else if (!this.authenticated) {
|
|
window.alert("Not authorized!")
|
|
} else {
|
|
if (this.#queue.length > 0) {
|
|
this.#keycloak.updateToken(30).then(() => {
|
|
if (this.#audience) {
|
|
console.log("Checking entitlement for audience", this.#audience)
|
|
const audience = encodeURIComponent(this.#audience)
|
|
return this.entitlement(audience)
|
|
} else {
|
|
return Promise.resolve(this.#keycloak.token)
|
|
}
|
|
|
|
}).then(token => {
|
|
console.log("Authorized. Token exp: " + this.expirationDate(this.parseJwt(token).exp))
|
|
//transform all queued requests to fetches
|
|
console.log("All pending requests to promises")
|
|
let promises = this.#queue.map(r => {
|
|
r.request.headers["Authorization"] = "Bearer " + token
|
|
return r.resolve( fetch(r.url, r.request) )
|
|
})
|
|
//clear queue
|
|
this.#queue = []
|
|
console.log("Resolving all fetches")
|
|
return Promise.all(promises)
|
|
|
|
}).catch(err => console.error("Unable to make calls: " + err)) // Sometimes throws: Unable to make calls: TypeError: Cannot read properties of undefined (reading 'split')
|
|
}
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
parseJwt(token) {
|
|
var base64Url = token.split('.')[1]
|
|
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
|
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
|
}).join(''))
|
|
|
|
return JSON.parse(jsonPayload);
|
|
}
|
|
|
|
expirationDate(utc) {
|
|
let d = new Date(0)
|
|
d.setUTCSeconds(utc)
|
|
return d
|
|
}
|
|
|
|
checkContext() {
|
|
const parseJwt = this.parseJwt
|
|
const expDt = this.expirationDate
|
|
const audience = encodeURIComponent(this.#audience)
|
|
this.entitlement(audience).then(function (rpt) {
|
|
// onGrant callback function.
|
|
// If authorization was successful you'll receive an RPT
|
|
// with the necessary permissions to access the resource server
|
|
//console.log(rpt)
|
|
//console.log("rpt expires: " + expDt(parseJwt(rpt).exp))
|
|
})
|
|
}
|
|
|
|
secureFetch(url, request) {
|
|
const p = new Promise((resolve, reject) => {
|
|
let req = request ? request : {}
|
|
if ("headers" in req) {
|
|
req.headers["Authorization"] = null
|
|
} else {
|
|
req.headers = { "Authorization" : null}
|
|
}
|
|
console.log("Queued request to url ", url)
|
|
this.#queue.push({ url : url, request : req, resolve : resolve, reject : reject})
|
|
})
|
|
return p
|
|
}
|
|
|
|
logout() {
|
|
if (this.#keycloak) {
|
|
if (!this.#redirectUrl) {
|
|
console.error("Missing required @redirectUrl attribute in d4s-boot")
|
|
} else {
|
|
this.#keycloak.logout({
|
|
redirectUri: this.#redirectUrl
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
loadConfig() {
|
|
const kc = this.#keycloak
|
|
return new Promise((resolve, reject) => {
|
|
fetch(kc.authServerUrl + '/realms/' + kc.realm + '/.well-known/uma2-configuration')
|
|
.then(response => response.json())
|
|
.then(json => {
|
|
this.#config = json
|
|
console.log("Keycloak uma2 configuration loaded")
|
|
resolve(true)
|
|
})
|
|
.catch(err => reject("Failed to fetch uma2-configuration from server: " + err))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Refactor of entitlement in '/js/keycloak-authz.js'
|
|
* (dependency no longer needed)
|
|
*/
|
|
entitlement(resourceServerId, authorizationRequest) {
|
|
return new Promise((resolve, reject) => {
|
|
const keycloak = this.#keycloak
|
|
const config = this.#config
|
|
if (!keycloak || !config) {
|
|
reject("Keycloak not initialized")
|
|
|
|
} else {
|
|
if (!authorizationRequest) {
|
|
authorizationRequest = {}
|
|
}
|
|
var params = "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket&client_id=" + keycloak.clientId
|
|
if (authorizationRequest.claimToken) {
|
|
params += "&claim_token=" + authorizationRequest.claimToken
|
|
if (authorizationRequest.claimTokenFormat) {
|
|
params += "&claim_token_format=" + authorizationRequest.claimTokenFormat
|
|
}
|
|
}
|
|
|
|
params += "&audience=" + resourceServerId
|
|
|
|
var permissions = authorizationRequest.permissions
|
|
|
|
if (!permissions) {
|
|
permissions = []
|
|
}
|
|
|
|
for (var i = 0; i < permissions.length; i++) {
|
|
var resource = permissions[i]
|
|
var permission = resource.id
|
|
|
|
if (resource.scopes && resource.scopes.length > 0) {
|
|
permission += "#"
|
|
for (j = 0; j < resource.scopes.length; j++) {
|
|
var scope = resource.scopes[j]
|
|
if (permission.indexOf('#') != permission.length - 1) {
|
|
permission += ","
|
|
}
|
|
permission += scope
|
|
}
|
|
}
|
|
|
|
params += "&permission=" + permission
|
|
}
|
|
|
|
var metadata = authorizationRequest.metadata
|
|
if (metadata) {
|
|
if (metadata.responseIncludeResourceName) {
|
|
params += "&response_include_resource_name=" + metadata.responseIncludeResourceName
|
|
}
|
|
if (metadata.responsePermissionsLimit) {
|
|
params += "&response_permissions_limit=" + metadata.responsePermissionsLimit
|
|
}
|
|
}
|
|
|
|
if (this.#rpt) {
|
|
params += "&rpt=" + this.#rpt
|
|
}
|
|
|
|
fetch(config.token_endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
"Content-type" : "application/x-www-form-urlencoded",
|
|
"Authorization" : "Bearer " + this.#keycloak.token
|
|
},
|
|
body: params
|
|
})
|
|
.then(response => response.json())
|
|
.then(json => {
|
|
this.#rpt = json.access_token
|
|
resolve(this.#rpt)
|
|
})
|
|
.catch(err => reject(err))
|
|
}
|
|
})
|
|
}
|
|
|
|
static get observedAttributes() {
|
|
return ["url", "realm", "gateway", "redirect-url", "context"];
|
|
}
|
|
|
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
if (oldValue !== newValue) {
|
|
switch (name) {
|
|
case "url":
|
|
this.#url = newValue
|
|
break
|
|
case "realm":
|
|
this.#realm = newValue
|
|
break
|
|
case "gateway":
|
|
this.#clientId = newValue
|
|
break
|
|
case "redirect-url":
|
|
this.#redirectUrl = newValue
|
|
break
|
|
case "context":
|
|
this.#audience = newValue
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
get authenticated(){
|
|
return this.#authenticated
|
|
}
|
|
|
|
get subject(){
|
|
return this.#keycloak.subject
|
|
}
|
|
|
|
get loginToken(){
|
|
return this.#keycloak.tokenParsed
|
|
}
|
|
|
|
get url() {
|
|
return this.#url
|
|
}
|
|
|
|
set url(url) {
|
|
this.#url = url
|
|
this.setAttribute("url", url)
|
|
}
|
|
|
|
get realm() {
|
|
return this.#realm
|
|
}
|
|
|
|
set realm(realm) {
|
|
this.#realm = realm
|
|
this.setAttribute("realm", realm)
|
|
}
|
|
|
|
get clientId() {
|
|
return this.#clientId
|
|
}
|
|
|
|
set clientId(clientId) {
|
|
this.#clientId = clientId
|
|
this.setAttribute("gateway", clientId)
|
|
}
|
|
|
|
get redirectUrl() {
|
|
return this.#redirectUrl
|
|
}
|
|
|
|
set redirectUrl(redirectUrl) {
|
|
this.#redirectUrl = redirectUrl
|
|
this.setAttribute("redirect-url", redirectUrl)
|
|
}
|
|
|
|
get context() {
|
|
return this.#audience
|
|
}
|
|
|
|
set context(context) {
|
|
this.#audience = context
|
|
this.setAttribute("context", context)
|
|
}
|
|
})
|
|
|