Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,6 @@ ENV/
# Docker
docker-compose.override.*.y*ml
docker-compose.override.y*ml

# static files
*/static/*
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,21 @@ The following environment variables are useful for configuring a local developme
* `DATASETS_INCLUDE`: A comma separated lists of datasets to expose using the API or to generate mock data for.
Default: `None` (expose all loaded datasets).

To connect to an authentication provider, set up the following environment variables:
To connect to an authentication provider, set up the following environment variables.

For Keycloak:
* `OAUTH_CLIENT_ID`: The client id of the application
* `OAUTH_JWKS_URL`: The JWKS URL of the authentication provider.
* `OAUTH_URL`: The auth URL of the authentication provider.

For Entra ID:
* `OAUTH_CLIENT_ID_ENTRA`: The client id of the application
* `OAUTH_JWKS_URL`: The JWKS URL of the authentication provider
* `OAUTH_AUTHORITY_ENTRA`: The authority url of the Microsoft account
* `OAUTH_CHECK_CLAIMS`: Dict containing the `aud` (audience) and `iss` (issuer) JWT claims e.g. "iss=...,aud=..."

When supporting multiple authentication providers, set up these environment variables:
* `OAUTH_JWKS_URLS`: List of the JWKS URLs of the authentication providers (when supporting multiple).
* `OAUTH_CHECK_CLAIMS`: Dict containing the `aud` (audience) and `iss` (issuer) JWT claims e.g. "iss=...,aud=..."

For all authentication providers exept Keycloak these `aud` and `iss` claims need to be verified in the authorization_django middleware

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ services:
AZURE_APPI_AUDIT_CONNECTION_STRING: "${AZURE_APPI_AUDIT_CONNECTION_STRING}"
AZURE_APPI_CONNECTION_STRING: "${AZURE_APPI_CONNECTION_STRING}"
OAUTH_CLIENT_ID: "${OAUTH_CLIENT_ID}"
OAUTH_CLIENT_ID_ENTRA: "${OAUTH_CLIENT_ID_ENTRA}"
OAUTH_JWKS_URL: "${OAUTH_JWKS_URL}"
OAUTH_JWKS_URLS: "${OAUTH_JWKS_URLS}"
OAUTH_CHECK_CLAIMS: "${OAUTH_CHECK_CLAIMS}"
PUB_JWKS: "${PUB_JWKS}"
OAUTH_URL: "${OAUTH_URL}"
OAUTH_AUTHORITY_ENTRA: "${OAUTH_AUTHORITY_ENTRA}"
CLOUD_ENV: "${CLOUD_ENV}"
DATAPUNT_API_URL: "${DATAPUNT_API_URL}"
DJANGO_DEBUG: 1
Expand Down
7 changes: 4 additions & 3 deletions src/.prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"semi": false,
"tabWidth": 4,
"useTabs": false
"semi": false,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5"
}
2 changes: 2 additions & 0 deletions src/dso_api/dynamic_api/views/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ def get_context_data(self, **kwargs):
if ds.has_geometry_fields
else None
),
"oauth_client_id_entra": settings.OAUTH_CLIENT_ID_ENTRA,
"oauth_authority_entra": settings.OAUTH_AUTHORITY_ENTRA,
}
)

Expand Down
6 changes: 3 additions & 3 deletions src/dso_api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@
SCHEMA_DEFS_URL = env.str("SCHEMA_DEFS_URL", "https://schemas.data.amsterdam.nl/schema")

# Authorization settings
OAUTH_URL = env.str(
"OAUTH_URL", "https://iam.amsterdam.nl/auth/realms/datapunt-ad/protocol/openid-connect/"
)
OAUTH_URL = env.str("OAUTH_URL", None)
OAUTH_AUTHORITY_ENTRA = env.str("OAUTH_AUTHORITY_ENTRA", None)
OAUTH_DEFAULT_SCOPE = env.str("OAUTH_DEFAULT_SCOPE", None)
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "dso-api-open-api")
OAUTH_CLIENT_ID_ENTRA = os.getenv("OAUTH_CLIENT_ID_ENTRA")


# -- Security
Expand Down
43 changes: 43 additions & 0 deletions src/dso_api/static/dso_api/dynamic_api/js/authorization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
function authorizeKeycloak() {
// Start authorization flow
authUrl = new URL(OAUTHURI)
authUrl.searchParams.set("client_id", CLIENTID)
authUrl.searchParams.set("redirect_uri", REDIRECTURI)
authUrl.searchParams.set("response_type", "token")
window.open(authUrl, "_blank")
}

// Entra ID authorization config
const msalConfig = {
auth: {
clientId: CLIENTID_ENTRA,
authority: AUTHORITY_ENTRA,
redirectUri: window.location.origin + "/v1",
},
}

async function authorizeEntra() {
const request = { scopes: [`${CLIENTID_ENTRA}/.default`] }
try {
const accounts = msalInstance.getAllAccounts()
if (accounts.length === 0) {
await msalInstance.loginRedirect({
scopes: [`${CLIENTID_ENTRA}/.default`],
})
} else if (accounts.length === 1) {
request.account = accounts[0]
try {
await msalInstance.acquireTokenRedirect(request)
} catch (error) {
console.log(error)
}
} else {
sessionStorage.clear()
}
} catch (error) {
console.log(error)
if (error.errorCode === "interaction_in_progress") {
sessionStorage.clear()
}
}
}
47 changes: 29 additions & 18 deletions src/dso_api/static/dso_api/dynamic_api/js/browsable_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ var dsoShowToken = (token) => {}
var oaParams = null
var oaSpec = null

// Entra ID module init
const msalInstance = new msal.PublicClientApplication(msalConfig)
let isInitialized = false

if (document.readyState != "loading") {
onPageLoad()
}
Expand Down Expand Up @@ -70,6 +74,10 @@ function onPageLoad() {
PAGEURL = newUrl
}

// Check if token is received when redirected from Entra log in
document.addEventListener("DOMContentLoaded", initializeMsal)
initializeMsal()

setURL(PAGEURL.href)
setParams()
setHeaders()
Expand Down Expand Up @@ -255,18 +263,22 @@ function updatePageRequest(
})
}

function authorize() {
// Start authorization flow
authUrl = new URL(OAUTHURI)
if (oaSpec) {
authUrl = new URL(
oaSpec.components.securitySchemes.oauth2.flows.implicit.authorizationUrl
)
async function initializeMsal() {
if (isInitialized) return

try {
const response = await msalInstance.handleRedirectPromise()
if (response) {
isInitialized = true
console.log(response)
window.localStorage.setItem("authToken", JSON.stringify(response))
addSetting("Authorization", "Bearer " + response.accessToken)
showHeaders()
}
} catch (error) {
console.error("Entra init failed:")
console.log(error)
Comment thread
JonaBenja marked this conversation as resolved.
}
authUrl.searchParams.set("client_id", CLIENTID)
authUrl.searchParams.set("redirect_uri", REDIRECTURI)
authUrl.searchParams.set("response_type", "token")
window.open(authUrl, "_blank")
}

function getRequestSettings(type = "params") {
Expand Down Expand Up @@ -453,9 +465,8 @@ function addSetting(key, value, op = "eq", type = "header", active = true) {
requestSettings
.filter((x) => x.key == key && x.operator == op && x.active)
.forEach((setting) => {
setting.element.getElementsByClassName(
"param-check"
)[0].checked = false
setting.element.getElementsByClassName("param-check")[0].checked =
false
setting.element
.getElementsByClassName("param-check")[0]
.dispatchEvent(new Event("change"))
Expand Down Expand Up @@ -1042,9 +1053,8 @@ function setInfoBubble(element) {
(new Date(token.exp * 1000) - new Date()) / 60000
)
expiredText = `Exp:${minutesRemaining}min`
summary = `${token.preferred_username} ${
isExpired ? "Expired" : expiredText
} `
const username = token.preferred_username || token.upn
summary = `${username} ${isExpired ? "Expired" : expiredText} `
infoEl.innerHTML = `<div class="summary">${summary}</div>`
infoEl.innerHTML += `<pre>${syntaxHighlight(
JSON.stringify(token, null, 4)
Expand Down Expand Up @@ -1079,9 +1089,10 @@ function setHeaders(headers = null) {
}
let token = JSON.parse(window.localStorage.getItem("authToken"))
if (token) {
var accessToken = token.access_token || token.accessToken
addSetting(
"Authorization",
"Bearer " + token.access_token,
"Bearer " + accessToken,
"eq",
"header",
false
Expand Down
113 changes: 99 additions & 14 deletions src/dso_api/static/dso_api/dynamic_api/js/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
}

// Override the same object from browsable_api.js, as this one we don't want to alter the DOM
// This is just for Keycloak
window.swaggerUIRedirectOauth2 = {
state: null,
redirectUrl: REDIRECTURI,
Expand All @@ -18,12 +19,29 @@
callback: (authorizationResult) => {
const token = authorizationResult.token
window.localStorage.setItem("authToken", JSON.stringify(token))
const alert = document.getElementById("auth-alert")
alert.remove()

// If exportURL is saved in local storage, download export
const url = window.localStorage.getItem("exportURL")
if (url) {
getData(url, {
Authorization: `Bearer ${token.access_token}`,
})
window.localStorage.removeItem("exportURL")
}
},
}

window.dsoShowToken = () => {}
// Entra ID module init
const msalInstance = new msal.PublicClientApplication(msalConfig)
let isInitialized = false

function onPageLoad() {
// Check if token is received when redirected from Entra log in
document.addEventListener("DOMContentLoaded", initializeMsal)
initializeMsal()

confidential_links = Array.from(
document.getElementsByClassName("confidential")
)
Expand All @@ -33,34 +51,77 @@
const url = link.href
const token = JSON.parse(window.localStorage.getItem("authToken"))
if (token) {
const tokenPayload = JSON.parse(
atob(token.access_token.split(".")[1])
)
var accessToken = token.access_token || token.accessToken
tokenPayload = JSON.parse(atob(accessToken.split(".")[1]))
const exp = new Date(tokenPayload.exp * 1000)
const now = new Date()
if (exp > now) {
getData(url, {
Authorization: `Bearer ${token.access_token}`,
Authorization: `Bearer ${accessToken}`,
})
} else {
authorize()
window.localStorage.setItem("exportURL", url)
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Dismissed
if (token.accessToken) {
authorizeKeycloak()
} else {
authorizeEntra()
}
}
} else {
authorize()
window.localStorage.setItem("exportURL", url)
Comment thread Dismissed
showAuthAlert(e)
}
})
})
}

function authorize() {
// Start authorization flow
authUrl = new URL(OAUTHURI)
authUrl.searchParams.set("client_id", CLIENTID)
authUrl.searchParams.set("redirect_uri", REDIRECTURI)
authUrl.searchParams.set("response_type", "token")
window.open(authUrl, "_blank")
function showAuthAlert(event) {
// Show alert with authorization buttons
const parent = event.target.parentNode.parentNode

const alert_banner = document.createElement("div")
alert_banner.id = "auth-alert"
alert_banner.style.backgroundColor = "#c5dcf1"
alert_banner.style.borderRadius = "5px"
alert_banner.style.padding = "15px"
alert_banner.style.margin = "10px"
alert_banner.innerHTML =
"<strong>Voor deze actie moet je ingelogd zijn. Kies één van onderstaande inlogmethodes:</strong>"
const btnDiv = document.createElement("div")
btnDiv.id = "auth-btn-div"
btnDiv.style.marginTop = "10px"

// Entra button
const entraButton = document.createElement("button")
entraButton.className = "btn btn-primary"
entraButton.style.backgroundColor = "green"
entraButton.innerText = "Authorize Entra ID"
entraButton.style.marginRight = "10px"
entraButton.addEventListener("click", authorizeEntra)
if (AUTHORITY_ENTRA == "None") {
entraButton.disabled = true
entraButton.title = "Entra authority is niet geconfigureerd."
}
btnDiv.appendChild(entraButton)

// KeyCloak button
const kcButton = document.createElement("button")
kcButton.className = "btn btn-primary"
kcButton.style.backgroundColor = "green"
kcButton.innerText = "Authorize KeyCloak"
kcButton.addEventListener("click", authorizeKeycloak)
if (OAUTHURI == "None") {
kcButton.disabled = true
kcButton.title = "Keycloak auth url is niet geconfigureerd."
}
btnDiv.appendChild(kcButton)

alert_banner.appendChild(btnDiv)
parent.appendChild(alert_banner)
}

window.dsoShowToken = () => {}

async function getData(blobUrl, headers) {
await fetch(blobUrl, { method: "GET", headers })
.then((response) => {
Expand All @@ -84,3 +145,27 @@
console.error("Error downloading file:", error)
})
}

async function initializeMsal() {
if (isInitialized) return

try {
const response = await msalInstance.handleRedirectPromise()
if (response) {
isInitialized = true
window.localStorage.setItem("authToken", JSON.stringify(response))

// If exportURL is saved in local storage, download export
if (window.localStorage.getItem("exportURL")) {
const url = window.localStorage.getItem("exportURL")
getData(url, {
Authorization: `Bearer ${response.accessToken}`,
})
window.localStorage.removeItem("exportURL")
}
}
} catch (error) {
console.error("Redirect processing failed:")
console.log(error)
Comment thread
JonaBenja marked this conversation as resolved.
}
}
2 changes: 2 additions & 0 deletions src/rest_framework_dso/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ def get_context(self, data, accepted_media_type, renderer_context):
# Maintain compatibility with other types of ViewSets
context["authorization_grantor"] = getattr(context["view"], "authorization_grantor", None)
context["oauth_url"] = settings.OAUTH_URL
context["oauth_authority_entra"] = settings.OAUTH_AUTHORITY_ENTRA
context["oauth_clientid_entra"] = settings.OAUTH_CLIENT_ID_ENTRA

# Insert formatter into context
if (
Expand Down
Loading
Loading