From 8155ffe0cefb0aadb1f13365e682e9ae8d000a4a Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Sat, 1 Feb 2025 11:24:48 +0100 Subject: [PATCH 01/19] Cosmetic --- public/styles.css | 10 +++++++--- src/App.vue | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/styles.css b/public/styles.css index 17d7c4c..2f458f4 100644 --- a/public/styles.css +++ b/public/styles.css @@ -2,11 +2,15 @@ body { font-family: Arial, Helvetica, sans-serif; background-color: floralwhite; margin: 0; + font-family: system-ui; } h1 { text-align: center; background-color: #f786867d; margin: 0 0 0.2em 0; + box-shadow: 0px 0.2em 0.2em #f786867d; + text-shadow: 0 0 2px #00000080; + font-weight: 200; } p { margin: 0.2em; @@ -184,8 +188,8 @@ hr { width: fit-content; align-items: center; margin: 5px auto; - box-shadow: 0px 0px 10px #90c3ee; - background-color: #90c3ee; + box-shadow: 0px 0px 10px #90c3eeA0; + background-color: #90c3eeA0; border-radius: 8px; } @@ -201,7 +205,7 @@ hr { color: darkred; } -.from-keycloak-vue { +.token-info { margin: 0 1em; } diff --git a/src/App.vue b/src/App.vue index fc161f9..aed43d5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -59,6 +59,7 @@ async function get_resource(id: string) { {{ keycloak.idTokenParsed?.oidc_provider }} </div> <button @click="accountManagemnt">Account management</button> + <button @click="manuallyRefreshAccessToken">Refresh access token</button> <button @click="logout" class="logout">Logout</button> </div> <hr> @@ -80,9 +81,8 @@ async function get_resource(id: string) { </div> </div> <div v-if="msg" class="msg resource">{{ msg }}</div> - <div v-if="settings.tokenSandbox" class="from-keycloak-vue"> + <div v-if="settings.tokenSandbox" class="token-info"> <hr> - <button @click="manuallyRefreshAccessToken">Refresh access token</button> <div> <h2>idToken</h2> <div class="token"> From 0782d9d12d23339c927685002f068feb92318e6d Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Sat, 1 Feb 2025 11:30:26 +0100 Subject: [PATCH 02/19] Cosmetic --- public/styles.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/public/styles.css b/public/styles.css index 2f458f4..da5e989 100644 --- a/public/styles.css +++ b/public/styles.css @@ -24,6 +24,9 @@ hr { .center { text-align: center; } +.error { + color: darkred; +} .content { width: 100%; display: flex; @@ -201,10 +204,6 @@ hr { text-align: center; } -.error { - color: darkred; -} - .token-info { margin: 0 1em; } From a0b391b4173cdea154b2d271a9409ec630e7a925 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Sat, 1 Feb 2025 16:15:52 +0100 Subject: [PATCH 03/19] Cosmetic --- public/styles.css | 10 +++++++++- src/App.vue | 12 ++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/public/styles.css b/public/styles.css index da5e989..a532917 100644 --- a/public/styles.css +++ b/public/styles.css @@ -104,10 +104,18 @@ hr { .hasResponseStatus.status-503 { background-color: #ffA88050; } -.role { +.role, .scope { padding: 3px 6px; + border-radius: 6px; + margin: 3px; +} +.role { background-color: #44228840; } +.scope { + background-color: #8888FF80; +} + /* For home */ diff --git a/src/App.vue b/src/App.vue index aed43d5..d05a079 100644 --- a/src/App.vue +++ b/src/App.vue @@ -49,14 +49,18 @@ async function get_resource(id: string) { <p>Hey, {{ keycloak.idTokenParsed?.name }}</p> <img v-if="keycloak.idTokenParsed?.picture" :src="keycloak.idTokenParsed.picture" class="picture"></img> <div>{{ keycloak.idTokenParsed?.email }}</div> - <div v-if="keycloak.resourceAccess && keycloak.resourceAccess['oidc-test-web']"> - <span>Roles:</span> - <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test-web'].roles" class="role">{{ + <div v-if="keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']"> + <span>Roles for oidc-test:</span> + <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']['roles']" class="role">{{ role }}</span> </div> <div v-if="keycloak.idTokenParsed?.oidc_provider"> <span>Provider:</span> - {{ keycloak.idTokenParsed?.oidc_provider }} + {{ keycloak.idTokenParsed.oidc_provider }} + </div> + <div v-if="keycloak.tokenParsed?.scope"> + <span>Scopes</span>: + <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope">{{ scope }}</span> </div> <button @click="accountManagemnt">Account management</button> <button @click="manuallyRefreshAccessToken">Refresh access token</button> From f7d2279d1995a885b846f7f8bab634aafb9d2d53 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 7 Feb 2025 04:58:47 +0100 Subject: [PATCH 04/19] Cleanup style --- public/styles.css | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/public/styles.css b/public/styles.css index a532917..3f9679d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -3,9 +3,9 @@ body { background-color: floralwhite; margin: 0; font-family: system-ui; + text-align: center; } h1 { - text-align: center; background-color: #f786867d; margin: 0 0 0.2em 0; box-shadow: 0px 0.2em 0.2em #f786867d; @@ -58,7 +58,6 @@ hr { border: 2px solid darkkhaki; padding: 3px 6px; text-decoration: none; - text-align: center; color: black; } .user-info a.logout:hover { @@ -73,7 +72,6 @@ hr { margin: 0; } .debug-auth p { - text-align: center; border-bottom: 1px solid black; } .debug-auth ul { @@ -104,14 +102,17 @@ hr { .hasResponseStatus.status-503 { background-color: #ffA88050; } + .role, .scope { padding: 3px 6px; - border-radius: 6px; margin: 3px; + border-radius: 6px; } + .role { background-color: #44228840; } + .scope { background-color: #8888FF80; } @@ -120,7 +121,6 @@ hr { /* For home */ .login-box { - text-align: center; background-color: antiquewhite; margin: 0.5em auto; width: fit-content; @@ -147,7 +147,6 @@ hr { max-height: 2em; } .providers .provider .link div { - text-align: center; background-color: #f7c7867d; border-radius: 8px; padding: 6px; @@ -162,13 +161,11 @@ hr { } .providers .error { padding: 3px 6px; - text-align: center; font-weight: bold; flex: 1 1 auto; } .content .links-to-check { display: flex; - text-align: center; justify-content: center; gap: 0.5em; flex-flow: wrap; From 24dcb7a9db40a6b26644bccb4265033dc2415e1e Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 7 Feb 2025 05:00:27 +0100 Subject: [PATCH 05/19] Add and automatic check links to resources --- src/App.vue | 32 ++++++++++++++++++++++++++++---- src/main.ts | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/App.vue b/src/App.vue index d05a079..3122449 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,10 @@ async function doAuthenticatedRequest() { const response = await authServer.get('/oidc-test-web') } +function getResourceUrl(url: string): string { + return settings.resourceServerUrl + "/" + url +} + function logout() { keycloak.logoutFn && keycloak.logoutFn() } @@ -34,7 +38,10 @@ async function get_resource(id: string) { msg.value = "" } ).catch( - err => msg.value = err + err => { + resource.value = [] + msg.value = `${err.message} (${err.response.statusText}): ${err.response.data["detail"]}` + } ) } </script> @@ -84,7 +91,24 @@ async function get_resource(id: string) { </div> </div> </div> - <div v-if="msg" class="msg resource">{{ msg }}</div> + <div v-if="msg" class="msg resource error">{{ msg }}</div> + <div class="content"> + <p> + These links should get different response codes depending on the authorization: + </p> + <div class="links-to-check"> + <a v-bind:href="getResourceUrl('public')">Public</a> + <a v-bind:href="getResourceUrl('protected')">Auth protected content</a> + <a v-bind:href="getResourceUrl('protected-by-foorole')">Auth + foorole protected content</a> + <a v-bind:href="getResourceUrl('protected-by-foorole-or-barrole')">Auth + foorole or barrole protected + content</a> + <a v-bind:href="getResourceUrl('protected-by-barrole')">Auth + barrole protected content</a> + <a v-bind:href="getResourceUrl('protected-by-foorole-and-barrole')">Auth + foorole and barrole protected + content</a> + <a v-bind:href="getResourceUrl('fast_api_depends')" class="hidden">Using FastAPI Depends</a> + <a v-bind:href="getResourceUrl('introspect')">Introspect token (401 expected)</a> + </div> + </div> <div v-if="settings.tokenSandbox" class="token-info"> <hr> <div> @@ -98,14 +122,14 @@ async function get_resource(id: string) { <h2>access token</h2> <div class="token"> <div v-for="(value, key) in keycloak.tokenParsed"> - <div class=" key">{{ key }}</div> + <div class="key">{{ key }}</div> <div class="value">{{ value }}</div> </div> </div> <h2>refresh token</h2> <div class="token"> <div v-for="(value, key) in keycloak.refreshTokenParsed"> - <div class=" key">{{ key }}</div> + <div class="key">{{ key }}</div> <div class="value">{{ value }}</div> </div> </div> diff --git a/src/main.ts b/src/main.ts index 427388e..b771875 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,7 @@ axios.get("settings.json").then().then( }, onReady(keycloak: Keycloak) { initializeTokenInterceptor(keycloak) + checkPerms('links-to-check') }, }) app.mount("#app") @@ -72,4 +73,25 @@ function initializeTokenInterceptor(keycloak: Keycloak) { }) } +async function checkHref(elem: HTMLLinkElement) { + await resourceServer.get(elem.href).then( + resp => { + elem.classList.add("hasResponseStatus") + elem.classList.add("status-" + resp.status) + elem.title = "Response code: " + resp.status + " - " + resp.statusText + }).catch(err => { + elem.classList.add("hasResponseStatus") + elem.classList.add("status-" + err.response.status) + elem.title = "Response code: " + err.response.status + " - " + err.response.statusText + }) +} + +function checkPerms(className: string) { + var rootElems = document.getElementsByClassName(className) + Array.from(rootElems).forEach(elem => + Array.from(elem.children).forEach(elem => checkHref(<HTMLLinkElement>elem)) + ) +} + + const app = createApp(App) From 553e85a5d562c1cc70888669b7ab405f3d7f8093 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 7 Feb 2025 13:20:30 +0100 Subject: [PATCH 06/19] Fix network error message for resource server --- src/App.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/App.vue b/src/App.vue index 3122449..2a4f53e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -40,7 +40,11 @@ async function get_resource(id: string) { ).catch( err => { resource.value = [] - msg.value = `${err.message} (${err.response.statusText}): ${err.response.data["detail"]}` + if (err.response) { + msg.value = `${err.message} (${err.response.statusText}): ${err.response.data["detail"]}` + } else { + msg.value = `${err.message} (cannot reach the resource server)` + } } ) } From 88423b26ddc52b83e84a424eb59ee4570120bf01 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 7 Feb 2025 16:45:27 +0100 Subject: [PATCH 07/19] Follow changes of oidc-test: move all resources to json; use buttons --- public/styles.css | 9 ++---- src/App.vue | 74 +++++++++++++++++++++++++---------------------- src/main.ts | 4 ++- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/public/styles.css b/public/styles.css index 3f9679d..cc7abeb 100644 --- a/public/styles.css +++ b/public/styles.css @@ -170,11 +170,12 @@ hr { gap: 0.5em; flex-flow: wrap; } -.content .links-to-check a { +.content .links-to-check button { color: black; padding: 5px 10px; text-decoration: none; border-radius: 8px; + border: none; } .token { @@ -182,12 +183,6 @@ hr { font-family: monospace; } -.actions { - display: flex; - justify-content: center; - gap: 0.5em; -} - .resource { padding: 0.5em; display: flex; diff --git a/src/App.vue b/src/App.vue index 2a4f53e..7fd21b1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ <script setup lang="ts"> -import { authServer, resourceServer, settings } from '@/main' +import { resourceServer, settings } from '@/main' import { ref } from 'vue' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' @@ -12,6 +12,7 @@ function manuallyRefreshAccessToken() { keycloak.keycloak && keycloak.keycloak.updateToken(5000) } +/* async function doAuthenticatedRequest() { // Doesn't really go anywhere, but as you see from the headers in the request // it contains the latest access token at all times @@ -21,6 +22,7 @@ async function doAuthenticatedRequest() { function getResourceUrl(url: string): string { return settings.resourceServerUrl + "/" + url } +*/ function logout() { keycloak.logoutFn && keycloak.logoutFn() @@ -30,8 +32,11 @@ function accountManagemnt() { keycloak.accountManagement && keycloak.accountManagement() } -async function get_resource(id: string) { - if (!keycloak.keycloak) { return } +async function get_resource(evt: MouseEvent) { + if (!keycloak.keycloak) return + if (!evt.target) return + const id: string | null = (<Element>evt.target).getAttribute("resource-id") + if (!id) return await resourceServer.get(id).then( resp => { resource.value = resp['data'] @@ -78,40 +83,39 @@ async function get_resource(id: string) { <button @click="logout" class="logout">Logout</button> </div> <hr> - <p class="center"> - Fetch resources from a resource server (at {{ settings.resourceServerUrl }}) - with your authentication token: - </p> - <div class="actions"> - <button @click="get_resource('time')">Time</button> - <button @click="get_resource('bs')">BS</button> - </div> - <div class="resources"> - <div v-if="Object.entries(resource).length > 0" class="resource"> - <div v-for="(value, key) in resource"> - <div class="key">{{ key }}</div> - <div v-if="key == 'sorry' || key == 'error'" class="error">{{ value }}</div> - <div v-else class="value">{{ value }}</div> + <div class="content"> + <p>Resources (at {{ settings.resourceServerUrl }}) validated by scope:</p> + <div class="links-to-check"> + <button resource-id="time" @click="get_resource($event)">Time</button> + <button resource-id="bs" @click="get_resource($event)">BS</button> + </div> + <p>Resources (at {{ settings.resourceServerUrl }}) validated by role:</p> + <div class="links-to-check"> + <button resource-id="public" @click="get_resource($event)">Public</button> + <button resource-id="protected" @click="get_resource($event)">Auth protected content</button> + <button resource-id="protected-by-foorole" @click="get_resource($event)">Auth + foorole protected + content</button> + <button resource-id="protected-by-foorole-or-barrole" @click="get_resource($event)">Auth + foorole or barrole + protected + content</button> + <button resource-id="protected-by-barrole" @click="get_resource($event)">Auth + barrole protected + content</button> + <button resource-id="protected-by-foorole-and-barrole" @click="get_resource($event)">Auth + foorole and barrole + protected + content</button> + <button resource-id="fast_api_depends" @click="get_resource($event)" class="hidden">Using FastAPI + Depends</button> + <!--<button resource-id="introspect" @click="get_resource($event)">Introspect token (401 expected)</button>--> + </div> + <div class="resources"> + <div v-if="Object.entries(resource).length > 0" class="resource"> + <div v-for="(value, key) in resource"> + <div class="key">{{ key }}</div> + <div class="value">{{ value }}</div> + </div> </div> </div> - </div> - <div v-if="msg" class="msg resource error">{{ msg }}</div> - <div class="content"> - <p> - These links should get different response codes depending on the authorization: - </p> - <div class="links-to-check"> - <a v-bind:href="getResourceUrl('public')">Public</a> - <a v-bind:href="getResourceUrl('protected')">Auth protected content</a> - <a v-bind:href="getResourceUrl('protected-by-foorole')">Auth + foorole protected content</a> - <a v-bind:href="getResourceUrl('protected-by-foorole-or-barrole')">Auth + foorole or barrole protected - content</a> - <a v-bind:href="getResourceUrl('protected-by-barrole')">Auth + barrole protected content</a> - <a v-bind:href="getResourceUrl('protected-by-foorole-and-barrole')">Auth + foorole and barrole protected - content</a> - <a v-bind:href="getResourceUrl('fast_api_depends')" class="hidden">Using FastAPI Depends</a> - <a v-bind:href="getResourceUrl('introspect')">Introspect token (401 expected)</a> - </div> + <div v-if="msg" class="msg resource error">{{ msg }}</div> </div> <div v-if="settings.tokenSandbox" class="token-info"> <hr> diff --git a/src/main.ts b/src/main.ts index b771875..fe9f63d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -74,7 +74,9 @@ function initializeTokenInterceptor(keycloak: Keycloak) { } async function checkHref(elem: HTMLLinkElement) { - await resourceServer.get(elem.href).then( + const url = elem.getAttribute("resource-id") + if (!url) return + await resourceServer.get(url).then( resp => { elem.classList.add("hasResponseStatus") elem.classList.add("status-" + resp.status) From 532c2f1f6f893821f606871d5babfca821d012ec Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Sat, 8 Feb 2025 01:57:43 +0100 Subject: [PATCH 08/19] Cosmetic --- public/styles.css | 1 + src/App.vue | 2 +- src/main.ts | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/styles.css b/public/styles.css index cc7abeb..36de6da 100644 --- a/public/styles.css +++ b/public/styles.css @@ -176,6 +176,7 @@ hr { text-decoration: none; border-radius: 8px; border: none; + cursor: pointer; } .token { diff --git a/src/App.vue b/src/App.vue index 7fd21b1..0e7614a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -120,7 +120,7 @@ async function get_resource(evt: MouseEvent) { <div v-if="settings.tokenSandbox" class="token-info"> <hr> <div> - <h2>idToken</h2> + <h2>id token</h2> <div class="token"> <div v-for="(value, key) in keycloak.idTokenParsed"> <div class="key">{{ key }}</div> diff --git a/src/main.ts b/src/main.ts index fe9f63d..f4bc87d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,6 @@ interface Settings { tokenSandbox: boolean } - export let settings: Settings export let authServer: AxiosInstance export let resourceServer: AxiosInstance From d3943fc0b20f8e4eb1b0271c03b1754eea1369ab Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Thu, 13 Feb 2025 12:21:25 +0100 Subject: [PATCH 09/19] Add ResourceButton component; refactoring --- src/App.vue | 106 +++++++++++++++++++++-------------------- src/ResourceButton.vue | 34 +++++++++++++ src/main.ts | 18 +++++-- 3 files changed, 101 insertions(+), 57 deletions(-) create mode 100644 src/ResourceButton.vue diff --git a/src/App.vue b/src/App.vue index 0e7614a..08c0ee9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,11 @@ <script setup lang="ts"> -import { resourceServer, settings } from '@/main' +import { resourceServer, settings, Resources } from '@/main' import { ref } from 'vue' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' +import ResourceButton from './ResourceButton.vue' -let resource = ref({}) +let resourceResponse = ref({}) +let plugins: Resources = ref({}) const keycloak = useKeycloak() let msg = ref("") @@ -12,18 +14,6 @@ function manuallyRefreshAccessToken() { keycloak.keycloak && keycloak.keycloak.updateToken(5000) } -/* -async function doAuthenticatedRequest() { - // Doesn't really go anywhere, but as you see from the headers in the request - // it contains the latest access token at all times - const response = await authServer.get('/oidc-test-web') -} - -function getResourceUrl(url: string): string { - return settings.resourceServerUrl + "/" + url -} -*/ - function logout() { keycloak.logoutFn && keycloak.logoutFn() } @@ -32,19 +22,25 @@ function accountManagemnt() { keycloak.accountManagement && keycloak.accountManagement() } -async function get_resource(evt: MouseEvent) { - if (!keycloak.keycloak) return - if (!evt.target) return - const id: string | null = (<Element>evt.target).getAttribute("resource-id") - if (!id) return - await resourceServer.get(id).then( +async function getResources() { + await resourceServer.get("").then( resp => { - resource.value = resp['data'] + plugins = resp.data["plugins"] + } + ) +} +getResources() + +async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { + const url = resource.default_resource_id ? `${resourceName}/${resource.default_resource_id}` : resourceName + await resourceServer.get(url).then( + resp => { + resourceResponse.value = resp['data'] msg.value = "" } ).catch( err => { - resource.value = [] + resourceResponse.value = [] if (err.response) { msg.value = `${err.message} (${err.response.statusText}): ${err.response.data["detail"]}` } else { @@ -62,21 +58,22 @@ async function get_resource(evt: MouseEvent) { Test the authentication and authorization, with OpenID Connect and OAuth2 with a Keycloak provider. </p> <div v-if="keycloak.authenticated" class="user-info"> - <p>Hey, {{ keycloak.idTokenParsed?.name }}</p> + <p>Hey, <span :innerText="keycloak.idTokenParsed?.name"></span></p> <img v-if="keycloak.idTokenParsed?.picture" :src="keycloak.idTokenParsed.picture" class="picture"></img> - <div>{{ keycloak.idTokenParsed?.email }}</div> + <div :innerText="keycloak.idTokenParsed?.email"></div> <div v-if="keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']"> <span>Roles for oidc-test:</span> - <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']['roles']" class="role">{{ - role }}</span> + <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']['roles']" + class="role" :innerText="role"> + </span> </div> <div v-if="keycloak.idTokenParsed?.oidc_provider"> <span>Provider:</span> - {{ keycloak.idTokenParsed.oidc_provider }} + <span :innerText="keycloak.idTokenParsed.oidc_provider"></span> </div> <div v-if="keycloak.tokenParsed?.scope"> <span>Scopes</span>: - <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope">{{ scope }}</span> + <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope" :innerText="scope"></span> </div> <button @click="accountManagemnt">Account management</button> <button @click="manuallyRefreshAccessToken">Refresh access token</button> @@ -84,38 +81,43 @@ async function get_resource(evt: MouseEvent) { </div> <hr> <div class="content"> - <p>Resources (at {{ settings.resourceServerUrl }}) validated by scope:</p> - <div class="links-to-check"> - <button resource-id="time" @click="get_resource($event)">Time</button> - <button resource-id="bs" @click="get_resource($event)">BS</button> - </div> <p>Resources (at {{ settings.resourceServerUrl }}) validated by role:</p> <div class="links-to-check"> - <button resource-id="public" @click="get_resource($event)">Public</button> - <button resource-id="protected" @click="get_resource($event)">Auth protected content</button> - <button resource-id="protected-by-foorole" @click="get_resource($event)">Auth + foorole protected + <button resource-name="public" @click="getResource($event)">Public</button> + <button resource-name="protected" @click="getResource($event)">Auth protected content</button> + <button resource-name="protected-by-foorole" @click="getResource($event)">Auth + foorole protected content</button> - <button resource-id="protected-by-foorole-or-barrole" @click="get_resource($event)">Auth + foorole or barrole + <button resource-name="protected-by-foorole-or-barrole" @click="getResource($event)">Auth + foorole or barrole protected content</button> - <button resource-id="protected-by-barrole" @click="get_resource($event)">Auth + barrole protected + <button resource-name="protected-by-barrole" @click="getResource($event)">Auth + barrole protected content</button> - <button resource-id="protected-by-foorole-and-barrole" @click="get_resource($event)">Auth + foorole and barrole + <button resource-name="protected-by-foorole-and-barrole" @click="getResource($event)">Auth + foorole and barrole protected content</button> - <button resource-id="fast_api_depends" @click="get_resource($event)" class="hidden">Using FastAPI + <button resource-name="fast_api_depends" @click="getResource($event)" class="hidden">Using FastAPI Depends</button> - <!--<button resource-id="introspect" @click="get_resource($event)">Introspect token (401 expected)</button>--> + <!--<button resource-id="introspect" @click="getResource($event)">Introspect token (401 expected)</button>--> + </div> + <p>Resource providers (validated by scope):</p> + <div class="links-to-check"> + <ResourceButton v-for="(value, key) in plugins" + :resourceName="key" + :resourceId="value.default_resource_id" + :innerText="key" + @getResource="getResource($event, key, value)" + > + </ResourceButton> </div> <div class="resources"> - <div v-if="Object.entries(resource).length > 0" class="resource"> - <div v-for="(value, key) in resource"> - <div class="key">{{ key }}</div> - <div class="value">{{ value }}</div> + <div v-if="Object.entries(resourceResponse).length > 0" class="resource"> + <div v-for="(value, key) in resourceResponse"> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> </div> </div> </div> - <div v-if="msg" class="msg resource error">{{ msg }}</div> + <div v-if="msg" class="msg resource error" :innetText="msg"></div> </div> <div v-if="settings.tokenSandbox" class="token-info"> <hr> @@ -123,22 +125,22 @@ async function get_resource(evt: MouseEvent) { <h2>id token</h2> <div class="token"> <div v-for="(value, key) in keycloak.idTokenParsed"> - <div class="key">{{ key }}</div> - <div class="value">{{ value }}</div> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> </div> </div> <h2>access token</h2> <div class="token"> <div v-for="(value, key) in keycloak.tokenParsed"> - <div class="key">{{ key }}</div> - <div class="value">{{ value }}</div> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> </div> </div> <h2>refresh token</h2> <div class="token"> <div v-for="(value, key) in keycloak.refreshTokenParsed"> - <div class="key">{{ key }}</div> - <div class="value">{{ value }}</div> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> </div> </div> </div> diff --git a/src/ResourceButton.vue b/src/ResourceButton.vue new file mode 100644 index 0000000..632c487 --- /dev/null +++ b/src/ResourceButton.vue @@ -0,0 +1,34 @@ +<script setup lang='ts'> +import { ref } from 'vue' +import { resourceServer } from '@/main' + +const props = defineProps({ + resourceName: String, + resourceId: String, +}) + +let _class: String = ref("") +let _title: String = ref("") + +const init = async (props) => { + const url = props.resourceId ? `${props.resourceName}/${props.resourceId}` : props.resourceName + await resourceServer.get(url).then( + resp => { + _class.value = `hasResponseStatus status-${resp.status}` + _title.value = `Response code: ${resp.status} - ${resp.statusText}` + } + ).catch( + err => { + _class.value = `hasResponseStatus status-${err.response.status}` + _title.value = `Response code: ${err.response.status} - ${err.response.statusText}` + } + ) +} + +init(props); + +</script> + +<template> + <button :class="_class" :title="_title" @click="$emit('getResource', $event)"></button> +</template> diff --git a/src/main.ts b/src/main.ts index f4bc87d..b9bfc24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { createApp } from 'vue' +import { createApp, ref } from 'vue' import Keycloak from "keycloak-js" import VueKeycloakJs from '@dsb-norge/vue-keycloak-js' import axios, { type AxiosInstance } from 'axios' @@ -15,6 +15,14 @@ interface Settings { tokenSandbox: boolean } +export interface Resource { + name: string +} + +export interface Resources { + [name: string]: Resource +} + export let settings: Settings export let authServer: AxiosInstance export let resourceServer: AxiosInstance @@ -72,8 +80,8 @@ function initializeTokenInterceptor(keycloak: Keycloak) { }) } -async function checkHref(elem: HTMLLinkElement) { - const url = elem.getAttribute("resource-id") +export async function checkResource(elem: HTMLLinkElement) { + const url = elem.getAttribute("resource-name") if (!url) return await resourceServer.get(url).then( resp => { @@ -88,11 +96,11 @@ async function checkHref(elem: HTMLLinkElement) { } function checkPerms(className: string) { + // Scan elements with className and check the respose var rootElems = document.getElementsByClassName(className) Array.from(rootElems).forEach(elem => - Array.from(elem.children).forEach(elem => checkHref(<HTMLLinkElement>elem)) + Array.from(elem.children).forEach(elem => checkResource(<HTMLLinkElement>elem)) ) } - const app = createApp(App) From d44bb6218765d6ed5cd10f8534cf8568a8cdc3f2 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Thu, 13 Feb 2025 15:12:17 +0100 Subject: [PATCH 10/19] Fix some typings --- src/App.vue | 41 +++++++++++++++++++++++++---------------- src/ResourceButton.vue | 10 +++++----- src/main.ts | 10 +--------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/App.vue b/src/App.vue index 08c0ee9..2b4aba5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,20 @@ <script setup lang="ts"> -import { resourceServer, settings, Resources } from '@/main' +import { resourceServer, settings } from '@/main' import { ref } from 'vue' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' import ResourceButton from './ResourceButton.vue' +export interface Resource{ + scope_required: string + default_resource_id: string +} + +export interface ResourceProviders { + [name: string]: Resource +} + let resourceResponse = ref({}) -let plugins: Resources = ref({}) +let plugins = ref<ResourceProviders>({}) const keycloak = useKeycloak() let msg = ref("") @@ -18,7 +27,7 @@ function logout() { keycloak.logoutFn && keycloak.logoutFn() } -function accountManagemnt() { +function accountManagement() { keycloak.accountManagement && keycloak.accountManagement() } @@ -31,7 +40,7 @@ async function getResources() { } getResources() -async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { +async function getResource(evt: MouseEvent, resource: Resource, resourceName: string) { const url = resource.default_resource_id ? `${resourceName}/${resource.default_resource_id}` : resourceName await resourceServer.get(url).then( resp => { @@ -75,13 +84,13 @@ async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { <span>Scopes</span>: <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope" :innerText="scope"></span> </div> - <button @click="accountManagemnt">Account management</button> + <button @click="accountManagement">Account management</button> <button @click="manuallyRefreshAccessToken">Refresh access token</button> <button @click="logout" class="logout">Logout</button> </div> <hr> <div class="content"> - <p>Resources (at {{ settings.resourceServerUrl }}) validated by role:</p> + <p>Resources at {{ settings.resourceServerUrl }} (validated by role):</p> <div class="links-to-check"> <button resource-name="public" @click="getResource($event)">Public</button> <button resource-name="protected" @click="getResource($event)">Auth protected content</button> @@ -101,23 +110,23 @@ async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { </div> <p>Resource providers (validated by scope):</p> <div class="links-to-check"> - <ResourceButton v-for="(value, key) in plugins" - :resourceName="key" - :resourceId="value.default_resource_id" - :innerText="key" - @getResource="getResource($event, key, value)" + <ResourceButton v-for="(resource, resourceName) in plugins" + :resourceName="<string>resourceName" + :resourceId="resource.default_resource_id" + :innerText="resourceName" + @getResource="getResource($event, resource, <string>resourceName)" > </ResourceButton> </div> <div class="resources"> <div v-if="Object.entries(resourceResponse).length > 0" class="resource"> <div v-for="(value, key) in resourceResponse"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> </div> - <div v-if="msg" class="msg resource error" :innetText="msg"></div> + <div v-if="msg" class="msg resource error" :innerText="msg"></div> </div> <div v-if="settings.tokenSandbox" class="token-info"> <hr> @@ -125,21 +134,21 @@ async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { <h2>id token</h2> <div class="token"> <div v-for="(value, key) in keycloak.idTokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> <h2>access token</h2> <div class="token"> <div v-for="(value, key) in keycloak.tokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> <h2>refresh token</h2> <div class="token"> <div v-for="(value, key) in keycloak.refreshTokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> diff --git a/src/ResourceButton.vue b/src/ResourceButton.vue index 632c487..02f6583 100644 --- a/src/ResourceButton.vue +++ b/src/ResourceButton.vue @@ -1,16 +1,16 @@ <script setup lang='ts'> -import { ref } from 'vue' +import { ref, type PropType } from 'vue' import { resourceServer } from '@/main' -const props = defineProps({ +const props: { [key: string]: String} = defineProps({ resourceName: String, resourceId: String, }) -let _class: String = ref("") -let _title: String = ref("") +let _class = ref<string>("") +let _title = ref<string>("") -const init = async (props) => { +const init = async (props: { [id: string]: any}) => { const url = props.resourceId ? `${props.resourceName}/${props.resourceId}` : props.resourceName await resourceServer.get(url).then( resp => { diff --git a/src/main.ts b/src/main.ts index b9bfc24..b12071b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,14 +15,6 @@ interface Settings { tokenSandbox: boolean } -export interface Resource { - name: string -} - -export interface Resources { - [name: string]: Resource -} - export let settings: Settings export let authServer: AxiosInstance export let resourceServer: AxiosInstance @@ -96,7 +88,7 @@ export async function checkResource(elem: HTMLLinkElement) { } function checkPerms(className: string) { - // Scan elements with className and check the respose + // Scan elements with className and check the response var rootElems = document.getElementsByClassName(className) Array.from(rootElems).forEach(elem => Array.from(elem.children).forEach(elem => checkResource(<HTMLLinkElement>elem)) From 6c5ef2f42c0c3c51df3e81b3f8a036635fd87802 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 14 Feb 2025 16:11:26 +0100 Subject: [PATCH 11/19] Start app when keycloak is ready; components --- src/App.vue | 106 ++++++++------------------------------- src/ResourceButton.vue | 28 ++++++++--- src/ResourceResponse.vue | 23 +++++++++ src/TokenView.vue | 30 +++++++++++ src/UserInfo.vue | 45 +++++++++++++++++ src/main.ts | 34 +++---------- 6 files changed, 144 insertions(+), 122 deletions(-) create mode 100644 src/ResourceResponse.vue create mode 100644 src/TokenView.vue create mode 100644 src/UserInfo.vue diff --git a/src/App.vue b/src/App.vue index 08c0ee9..c120088 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,13 +1,16 @@ <script setup lang="ts"> -import { resourceServer, settings, Resources } from '@/main' +import { resourceServer, settings, type Resource, type Resources } from '@/main' import { ref } from 'vue' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' import ResourceButton from './ResourceButton.vue' +import UserInfo from './UserInfo.vue' +import TokenView from './TokenView.vue' +import ResourceResponse from './ResourceResponse.vue' -let resourceResponse = ref({}) -let plugins: Resources = ref({}) const keycloak = useKeycloak() -let msg = ref("") +let resourceResponse = ref({}) +let resources = ref<Resources>({}) +let msg = ref<string>("") function manuallyRefreshAccessToken() { // We set a high minValidity to force a token refresh @@ -18,20 +21,20 @@ function logout() { keycloak.logoutFn && keycloak.logoutFn() } -function accountManagemnt() { +function accountManagement() { keycloak.accountManagement && keycloak.accountManagement() } async function getResources() { await resourceServer.get("").then( resp => { - plugins = resp.data["plugins"] + resources.value = resp.data["plugins"] } ) } getResources() -async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { +async function getResource(evt: MouseEvent, resourceName: string, resource: Resource) { const url = resource.default_resource_id ? `${resourceName}/${resource.default_resource_id}` : resourceName await resourceServer.get(url).then( resp => { @@ -54,96 +57,27 @@ async function getResource(evt: MouseEvent, resourceName: str, resource: {}) { <template> <div id="app"> <h1>OIDC-test - web client</h1> - <p class="center"> + <p> Test the authentication and authorization, with OpenID Connect and OAuth2 with a Keycloak provider. </p> - <div v-if="keycloak.authenticated" class="user-info"> - <p>Hey, <span :innerText="keycloak.idTokenParsed?.name"></span></p> - <img v-if="keycloak.idTokenParsed?.picture" :src="keycloak.idTokenParsed.picture" class="picture"></img> - <div :innerText="keycloak.idTokenParsed?.email"></div> - <div v-if="keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']"> - <span>Roles for oidc-test:</span> - <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']['roles']" - class="role" :innerText="role"> - </span> - </div> - <div v-if="keycloak.idTokenParsed?.oidc_provider"> - <span>Provider:</span> - <span :innerText="keycloak.idTokenParsed.oidc_provider"></span> - </div> - <div v-if="keycloak.tokenParsed?.scope"> - <span>Scopes</span>: - <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope" :innerText="scope"></span> - </div> - <button @click="accountManagemnt">Account management</button> - <button @click="manuallyRefreshAccessToken">Refresh access token</button> - <button @click="logout" class="logout">Logout</button> - </div> + <UserInfo></UserInfo> <hr> <div class="content"> - <p>Resources (at {{ settings.resourceServerUrl }}) validated by role:</p> + <p>These resources are available at this authentication provider:</p> <div class="links-to-check"> - <button resource-name="public" @click="getResource($event)">Public</button> - <button resource-name="protected" @click="getResource($event)">Auth protected content</button> - <button resource-name="protected-by-foorole" @click="getResource($event)">Auth + foorole protected - content</button> - <button resource-name="protected-by-foorole-or-barrole" @click="getResource($event)">Auth + foorole or barrole - protected - content</button> - <button resource-name="protected-by-barrole" @click="getResource($event)">Auth + barrole protected - content</button> - <button resource-name="protected-by-foorole-and-barrole" @click="getResource($event)">Auth + foorole and barrole - protected - content</button> - <button resource-name="fast_api_depends" @click="getResource($event)" class="hidden">Using FastAPI - Depends</button> - <!--<button resource-id="introspect" @click="getResource($event)">Introspect token (401 expected)</button>--> - </div> - <p>Resource providers (validated by scope):</p> - <div class="links-to-check"> - <ResourceButton v-for="(value, key) in plugins" - :resourceName="key" - :resourceId="value.default_resource_id" - :innerText="key" - @getResource="getResource($event, key, value)" + <ResourceButton v-for="(resource, name) in resources" + :resourceName="name as any" + :resourceId="resource.default_resource_id" + :innerText="resource.name" + @getResource="getResource($event, name as string, resource)" > </ResourceButton> </div> - <div class="resources"> - <div v-if="Object.entries(resourceResponse).length > 0" class="resource"> - <div v-for="(value, key) in resourceResponse"> - <div class="key" :innetText="key"></div> - <div class="value" :innerText="value"></div> - </div> - </div> - </div> - <div v-if="msg" class="msg resource error" :innetText="msg"></div> + <ResourceResponse :resourceResponse="resourceResponse" :err="msg"></ResourceResponse> </div> <div v-if="settings.tokenSandbox" class="token-info"> <hr> - <div> - <h2>id token</h2> - <div class="token"> - <div v-for="(value, key) in keycloak.idTokenParsed"> - <div class="key" :innetText="key"></div> - <div class="value" :innerText="value"></div> - </div> - </div> - <h2>access token</h2> - <div class="token"> - <div v-for="(value, key) in keycloak.tokenParsed"> - <div class="key" :innetText="key"></div> - <div class="value" :innerText="value"></div> - </div> - </div> - <h2>refresh token</h2> - <div class="token"> - <div v-for="(value, key) in keycloak.refreshTokenParsed"> - <div class="key" :innetText="key"></div> - <div class="value" :innerText="value"></div> - </div> - </div> - </div> + <TokenView></TokenView> </div> </div> </template> diff --git a/src/ResourceButton.vue b/src/ResourceButton.vue index 632c487..180406a 100644 --- a/src/ResourceButton.vue +++ b/src/ResourceButton.vue @@ -1,16 +1,28 @@ <script setup lang='ts'> -import { ref } from 'vue' +import { ref, type PropType, type ComponentObjectPropsOptions } from 'vue' import { resourceServer } from '@/main' -const props = defineProps({ - resourceName: String, - resourceId: String, +interface Props { + resourceName: string, + resourceId?: string, +} + +const props = defineProps<Props>() + +/* +const props = defineProps<ComponentObjectPropsOptions<Props>>({ + resourceName: { + type: String, + required: true + }, + resourceId: { type: String}, }) +*/ +let _class = ref<string>("") +let _title = ref<string>("") -let _class: String = ref("") -let _title: String = ref("") - -const init = async (props) => { +const init = async (props: any) => { + // Get code at component boot time const url = props.resourceId ? `${props.resourceName}/${props.resourceId}` : props.resourceName await resourceServer.get(url).then( resp => { diff --git a/src/ResourceResponse.vue b/src/ResourceResponse.vue new file mode 100644 index 0000000..63f9f24 --- /dev/null +++ b/src/ResourceResponse.vue @@ -0,0 +1,23 @@ +<script setup lang='ts'> +import { ref, type ComponentObjectPropsOptions } from 'vue' + +interface Props { + resourceResponse: {} + err: string +} + +const props = defineProps<Props>() + +</script> + +<template> + <div class="resources"> + <div v-if="Object.entries(resourceResponse).length > 0" class="resource"> + <div v-for="(value, key) in resourceResponse"> + <div class="key" :innerText="key"></div> + <div class="value" :innerText="value"></div> + </div> + </div> + </div> + <div v-if="err" class="msg resource error" :innerText="err"></div> +</template> diff --git a/src/TokenView.vue b/src/TokenView.vue new file mode 100644 index 0000000..91b34eb --- /dev/null +++ b/src/TokenView.vue @@ -0,0 +1,30 @@ +<script setup lang='ts'> +import { ref } from 'vue' +import { useKeycloak } from '@dsb-norge/vue-keycloak-js' + +const keycloak = useKeycloak() +</script> + +<template> + <h2>id token</h2> + <div class="token"> + <div v-for="(value, key) in keycloak.idTokenParsed"> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> + </div> + </div> + <h2>access token</h2> + <div class="token"> + <div v-for="(value, key) in keycloak.tokenParsed"> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> + </div> + </div> + <h2>refresh token</h2> + <div class="token"> + <div v-for="(value, key) in keycloak.refreshTokenParsed"> + <div class="key" :innetText="key"></div> + <div class="value" :innerText="value"></div> + </div> + </div> +</template> diff --git a/src/UserInfo.vue b/src/UserInfo.vue new file mode 100644 index 0000000..cfc1c31 --- /dev/null +++ b/src/UserInfo.vue @@ -0,0 +1,45 @@ +<script setup lang='ts'> +import { ref, type ComponentObjectPropsOptions } from 'vue' +import { useKeycloak } from '@dsb-norge/vue-keycloak-js' + +const keycloak = useKeycloak() + +function manuallyRefreshAccessToken() { + // We set a high minValidity to force a token refresh + keycloak.keycloak && keycloak.keycloak.updateToken(5000) +} + +function logout() { + keycloak.logoutFn && keycloak.logoutFn() +} + +function accountManagement() { + keycloak.accountManagement && keycloak.accountManagement() +} + +</script> + +<template> + <div v-if="keycloak.authenticated" class="user-info"> + <p>Hey, <span :innerText="keycloak.idTokenParsed?.name"></span></p> + <img v-if="keycloak.idTokenParsed?.picture" :src="keycloak.idTokenParsed.picture" class="picture"></img> + <div :innerText="keycloak.idTokenParsed?.email"></div> + <div v-if="keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']"> + <span>Roles for oidc-test:</span> + <span v-for="role in keycloak.resourceAccess && keycloak.resourceAccess['oidc-test']['roles']" + class="role" :innerText="role"> + </span> + </div> + <div v-if="keycloak.idTokenParsed?.oidc_provider"> + <span>Provider:</span> + <span :innerText="keycloak.idTokenParsed.oidc_provider"></span> + </div> + <div v-if="keycloak.tokenParsed?.scope"> + <span>Scopes</span>: + <span v-for="scope in keycloak.tokenParsed.scope.split(' ')" class="scope" :innerText="scope"></span> + </div> + <button @click="accountManagement">Account management</button> + <button @click="manuallyRefreshAccessToken">Refresh access token</button> + <button @click="logout" class="logout">Logout</button> + </div> +</template> diff --git a/src/main.ts b/src/main.ts index b9bfc24..e2ab97d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,8 @@ -import { createApp, ref } from 'vue' +import { createApp } from 'vue' import Keycloak from "keycloak-js" import VueKeycloakJs from '@dsb-norge/vue-keycloak-js' import axios, { type AxiosInstance } from 'axios' -import App from './App.vue' +import App from '@/App.vue' interface Settings { keycloakUri: string @@ -17,6 +17,9 @@ interface Settings { export interface Resource { name: string + default_resource_id: string + role_required: string + scope_required: string } export interface Resources { @@ -51,14 +54,12 @@ axios.get("settings.json").then().then( }, onReady(keycloak: Keycloak) { initializeTokenInterceptor(keycloak) - checkPerms('links-to-check') + app.mount("#app") }, }) - app.mount("#app") } ) - function initializeTokenInterceptor(keycloak: Keycloak) { authServer.interceptors.request.use(axiosSettings => { if (keycloak.authenticated) { @@ -80,27 +81,4 @@ function initializeTokenInterceptor(keycloak: Keycloak) { }) } -export async function checkResource(elem: HTMLLinkElement) { - const url = elem.getAttribute("resource-name") - if (!url) return - await resourceServer.get(url).then( - resp => { - elem.classList.add("hasResponseStatus") - elem.classList.add("status-" + resp.status) - elem.title = "Response code: " + resp.status + " - " + resp.statusText - }).catch(err => { - elem.classList.add("hasResponseStatus") - elem.classList.add("status-" + err.response.status) - elem.title = "Response code: " + err.response.status + " - " + err.response.statusText - }) -} - -function checkPerms(className: string) { - // Scan elements with className and check the respose - var rootElems = document.getElementsByClassName(className) - Array.from(rootElems).forEach(elem => - Array.from(elem.children).forEach(elem => checkResource(<HTMLLinkElement>elem)) - ) -} - const app = createApp(App) From 7a379934399d53797d83cb361aff6ab40d497c2a Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 21 Feb 2025 01:20:35 +0100 Subject: [PATCH 12/19] Fix typo --- src/TokenView.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TokenView.vue b/src/TokenView.vue index 91b34eb..b8287bf 100644 --- a/src/TokenView.vue +++ b/src/TokenView.vue @@ -9,21 +9,21 @@ const keycloak = useKeycloak() <h2>id token</h2> <div class="token"> <div v-for="(value, key) in keycloak.idTokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> <h2>access token</h2> <div class="token"> <div v-for="(value, key) in keycloak.tokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> <h2>refresh token</h2> <div class="token"> <div v-for="(value, key) in keycloak.refreshTokenParsed"> - <div class="key" :innetText="key"></div> + <div class="key" :innerText="key"></div> <div class="value" :innerText="value"></div> </div> </div> From d2dcfb6e2e52eef3b90ab75407aaf540ae86a39e Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Fri, 21 Feb 2025 13:45:18 +0100 Subject: [PATCH 13/19] Support third party resource providers --- public/styles.css | 3 +++ src/App.vue | 27 +++++++++++++++---- src/ResourceButton.vue | 10 ++++--- src/main.ts | 60 +++++++++++++++++++++++++++++++++--------- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/public/styles.css b/public/styles.css index 36de6da..8d4804f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -178,6 +178,9 @@ hr { border: none; cursor: pointer; } +.content .links-to-check span { + margin: auto; +} .token { overflow-wrap: anywhere; diff --git a/src/App.vue b/src/App.vue index c120088..006f88b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,9 @@ <script setup lang="ts"> -import { resourceServer, settings, type Resource, type Resources } from '@/main' import { ref } from 'vue' +import { type AxiosInstance } from 'axios' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' + +import { resourceServer, settings, axiosResourceProviders, type Resource, type Resources } from '@/main' import ResourceButton from './ResourceButton.vue' import UserInfo from './UserInfo.vue' import TokenView from './TokenView.vue' @@ -34,9 +36,10 @@ async function getResources() { } getResources() -async function getResource(evt: MouseEvent, resourceName: string, resource: Resource) { +async function getResource(evt: MouseEvent, resourceName: string, resource: Resource, resourceProviderId?: string) { const url = resource.default_resource_id ? `${resourceName}/${resource.default_resource_id}` : resourceName - await resourceServer.get(url).then( + const axiosClient: AxiosInstance = resourceProviderId ? axiosResourceProviders[resourceProviderId] : resourceServer + await axiosClient.get(url).then( resp => { resourceResponse.value = resp['data'] msg.value = "" @@ -66,13 +69,27 @@ async function getResource(evt: MouseEvent, resourceName: string, resource: Reso <p>These resources are available at this authentication provider:</p> <div class="links-to-check"> <ResourceButton v-for="(resource, name) in resources" - :resourceName="name as any" + :resourceName="name.toString()" :resourceId="resource.default_resource_id" :innerText="resource.name" - @getResource="getResource($event, name as string, resource)" + @getResource="getResource($event, name.toString(), resource)" > </ResourceButton> </div> + <p>These resoures are available from third party resource providers:</p> + <div v-for="(resourceProvider, resourceProviderId) in settings.resourceProviders"> + <div class="links-to-check"> + <span :innerText="`${resourceProvider.name}: `"></span> + <ResourceButton v-for="(resource, name) in resourceProvider.resources" + :resourceName="name.toString()" + :resourceId="resource.default_resource_id" + :innerText="resource.name" + :resourceProviderId="resourceProviderId" + @getResource="getResource($event, name.toString(), resource, resourceProviderId.toString())" + > + </ResourceButton> + </div> + </div> <ResourceResponse :resourceResponse="resourceResponse" :err="msg"></ResourceResponse> </div> <div v-if="settings.tokenSandbox" class="token-info"> diff --git a/src/ResourceButton.vue b/src/ResourceButton.vue index 180406a..8c787cc 100644 --- a/src/ResourceButton.vue +++ b/src/ResourceButton.vue @@ -1,10 +1,13 @@ <script setup lang='ts'> import { ref, type PropType, type ComponentObjectPropsOptions } from 'vue' -import { resourceServer } from '@/main' +import { type AxiosInstance } from 'axios' + +import { resourceServer, axiosResourceProviders } from '@/main' interface Props { resourceName: string, - resourceId?: string, + resourceProviderId?: string | number, + resourceId?: string | null, } const props = defineProps<Props>() @@ -23,8 +26,9 @@ let _title = ref<string>("") const init = async (props: any) => { // Get code at component boot time + const axiosResourceProvider: AxiosInstance = props.resourceProviderId ? axiosResourceProviders[props.resourceProviderId] : resourceServer const url = props.resourceId ? `${props.resourceName}/${props.resourceId}` : props.resourceName - await resourceServer.get(url).then( + await axiosResourceProvider.get(url).then( resp => { _class.value = `hasResponseStatus status-${resp.status}` _title.value = `Response code: ${resp.status} - ${resp.statusText}` diff --git a/src/main.ts b/src/main.ts index e2ab97d..459b1b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,20 +1,9 @@ import { createApp } from 'vue' import Keycloak from "keycloak-js" import VueKeycloakJs from '@dsb-norge/vue-keycloak-js' -import axios, { type AxiosInstance } from 'axios' +import axios, { Axios, type AxiosInstance } from 'axios' import App from '@/App.vue' -interface Settings { - keycloakUri: string - realm: string - clientId: string - sso: boolean - resourceServerUrl: string - resourceScopes: string[] - authProvider: string - tokenSandbox: boolean -} - export interface Resource { name: string default_resource_id: string @@ -26,9 +15,38 @@ export interface Resources { [name: string]: Resource } +interface ResourceProvider { + id: string + name: string + baseUrl: string + verifySSL: boolean + resources: Resources +} + +export interface ResourceProviders { + [name: string]: ResourceProvider +} + +interface Settings { + keycloakUri: string + realm: string + clientId: string + sso: boolean + resourceServerUrl: string + resourceScopes: string[] + authProvider: string + tokenSandbox: boolean + resourceProviders: ResourceProviders +} + +interface AxiosResourceProviders { + [name: string]: AxiosInstance +} + export let settings: Settings export let authServer: AxiosInstance export let resourceServer: AxiosInstance +export let axiosResourceProviders: AxiosResourceProviders = {} // The settings.json file is expected at the server's base url axios.get("settings.json").then().then( @@ -61,6 +79,24 @@ axios.get("settings.json").then().then( ) function initializeTokenInterceptor(keycloak: Keycloak) { + Object.entries(settings.resourceProviders).forEach( + ([id, resourceProvider]) => { + const rp = axios.create({ + baseURL: resourceProvider.baseUrl, + timeout: 10000 + }) + rp.interceptors.request.use(axiosSettings => { + if (keycloak.authenticated) { + axiosSettings.headers.Authorization = `Bearer ${keycloak.token}` + axiosSettings.headers.auth_provider = settings.authProvider + } + return axiosSettings + }, error => { + return Promise.reject(error) + }) + axiosResourceProviders[id] = rp + } + ) authServer.interceptors.request.use(axiosSettings => { if (keycloak.authenticated) { axiosSettings.headers.Authorization = `Bearer ${keycloak.token}` From 4e566d736d0d65842a17c0f89833181fd1246f9c Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Mon, 24 Feb 2025 02:31:23 +0100 Subject: [PATCH 14/19] CI: build container --- .forgejo/workflows/build.yaml | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 5a0bcc2..cf2172f 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -3,7 +3,7 @@ on: workflow_dispatch: inputs: build: - description: "Build package" + description: "Build package and container" required: true default: false type: boolean @@ -70,3 +70,39 @@ jobs: - name: Publish if: fromJSON(steps.builder.outputs.run) run: pnpm publish --no-git-checks + + - name: Build container + if: fromJSON(steps.builder.outputs.run) + uses: actions/buildah-build@v1 + with: + image: oidc-vue-test + oci: true + labels: oidc-vue-test + tags: latest ${{ steps.version.outputs.version }} + containerfiles: | + ./Containerfile + build-args: | + APP_VERSION=${{ steps.version.outputs.version }} + + - name: Workaround for bug of podman-login + if: fromJSON(steps.builder.outputs.run) + run: | + mkdir -p $HOME/.docker + echo "{ \"auths\": {} }" > $HOME/.docker/config.json + + - name: Log in to container registry (with another workaround) + if: fromJSON(steps.builder.outputs.run) + uses: actions/podman-login@v1 + with: + registry: ${{ vars.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + auth_file_path: /tmp/auth.json + + - name: Push the image to the registry + if: fromJSON(steps.builder.outputs.run) + uses: actions/push-to-registry@v2 + with: + registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}" + image: oidc-vue-test + tags: latest ${{ steps.version.outputs.version }} From 5a807657298c9d7a04f74fbc1d17e10adaa149d9 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Mon, 24 Feb 2025 02:56:33 +0100 Subject: [PATCH 15/19] CI: fix container (path) --- Containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Containerfile b/Containerfile index e9815a2..e5f8be0 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,3 @@ FROM docker.io/nginx:alpine -COPY ./dist /usr/share/nginx/html +COPY ./dist /usr/share/nginx/html/oidc-test-web From 573fb0335b50fec45b8f360ef1e73351a69706e2 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Mon, 24 Feb 2025 04:23:46 +0100 Subject: [PATCH 16/19] Update doc (README) --- README.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 77693ed..271f522 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,123 @@ Small web app for experimenting a web app with a Keycloak auth server. -It is a sibbling of the server version (oidc-test)[philorg/oidc-fastapi-test], +It is a sibling of the server version [oidc-test](philorg/oidc-fastapi-test), which acts also as a resource server. -Live demo: https://philo.ydns.eu/oidc-test-web: +Live demo: <https://philo.ydns.eu/oidc-test-web>: + - configured with a test realm on a private Keycloak instance - 2 users are defined: foo (foofoo) and bar (barbar). -## Deployment +**Note**: decoding tokens requires the use of cryto extension, +that web browsers allow only with a secured connection (https). -In a container: +## Configuration + +The app expects that a `settings.json` file is available on the server +at the app's base url. + +For example: + +```json +{ + "keycloakUri": "https://keycloak.your.domain", + "realm": "test", + "authProvider": "keycloak", + "sso": false, + "clientId": "oidc-test-web", + "tokenSandbox": true, + "resourceServerUrl": "https://someserver.your.domain/resourceBaseUrl", + "resourceScopes": [ + "get:time", + "get:bs" + ], + "resourceProviders": { + "resourceProvider1": { + "name": "Third party 1", + "baseUrl": "https://otherserver.your.domain/resources/", + "verifySSL": true, + "resources": { + "public": { + "name": "A public resource", + "url": "resource/public" + }, + "bs": { + "name": "A secured resource, eg by scope", + "url": "resource/secured1" + }, + "time": { + "name": "Another secured resource, eg by role", + "url": "resource/secured2" + } + } + } + } +} +``` + +## Build + +For generating a `dist` directory ready to be copied to a web server +static data tree, it's a straightforward: + +```sh +pnpm run build +``` + +Eventually specify a `base url` (eg. accessible from `https://for.example.com/oidc-test-web`): ```sh pnpm run build --base oidc-test-web -podman run -it --rm -p 8874:80 -v ./dist:/usr/share/nginx/html/oidc-test-web docker.io/nginx:alpine +``` + +## Deployment + +Examples of deployment are presented below. + +- Using the nginx default container, from the development source tree: + +```sh +podman run -it --rm -p 8874:80 -v ./dist:/usr/share/nginx/html/oidc-test-web -v ./settings.json:/usr/share/nginx/html/oidc-test-web/settings.json docker.io/nginx:alpine +``` + +- The build is packaged in a provided container (see *pakcages*), serving with the `/oidc-test-web` base url: + +```sh +podman run -it --rm -p 8874:80 -v ./settings.json:/usr/share/nginx/html/oidc-test-web/settings.json code.philo.ydns.eu/philorg/oidc-vue-test:latest +``` + +- A *quadlet* *systemd* service, in `~/.config/containers/systemd/oidc-vue-test.container`: + +```systemd +[Container] +ContainerName=oidc-vue-test +Image=code.philo.ydns.eu/philorg/oidc-vue-test:latest +Mount=type=bind,source=/path/to/settings.json,destination=/usr/share/nginx/html/oidc-test-web/settings.json +PublishPort=8874:80 + +[Service] +Restart=always +RestartSec=5 + +[Unit] +After=podman-user-wait-network-online.service + +[Install] +WantedBy=default.target +``` + +Run with: + +```sh +systemctl --user daemon-reload +systemcrl --user start oidc-vue-test ``` ## Frontend +YMMV, easy with *Caddy*: + ```Caddyfile handle /oidc-test-web { reverse-proxy hostname.domainame:8874 From cc384c4079fe88dfd9cdb914a669818bc36b18dc Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Tue, 25 Feb 2025 13:41:31 +0100 Subject: [PATCH 17/19] CI: add run command to run nginx --- Containerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Containerfile b/Containerfile index e5f8be0..f5612bd 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,5 @@ FROM docker.io/nginx:alpine COPY ./dist /usr/share/nginx/html/oidc-test-web + +CMD ["nginx", "-g", "daemon off;"] From a26eefacb112af9a0592ad6302ff67650f85eee6 Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Tue, 25 Feb 2025 13:44:43 +0100 Subject: [PATCH 18/19] Firx typo in package name --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee57e60..360dd30 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "oidc-text-web", + "name": "oidc-test-web", "version": "0.0.0", "type": "module", "scripts": { From 2e878a537006138bd07d8c5c221677b4fe52f80f Mon Sep 17 00:00:00 2001 From: phil <phil.dev@philome.mooo.com> Date: Tue, 25 Feb 2025 14:55:27 +0100 Subject: [PATCH 19/19] CI: continue if pnpm publish fails (package version already exists) --- .forgejo/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index cf2172f..53c75fe 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -70,6 +70,7 @@ jobs: - name: Publish if: fromJSON(steps.builder.outputs.run) run: pnpm publish --no-git-checks + continue-on-error: true - name: Build container if: fromJSON(steps.builder.outputs.run)