diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 5a0bcc2..53c75fe 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,40 @@ 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) + 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 }} diff --git a/Containerfile b/Containerfile index e9815a2..f5612bd 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,5 @@ FROM docker.io/nginx:alpine -COPY ./dist /usr/share/nginx/html +COPY ./dist /usr/share/nginx/html/oidc-test-web + +CMD ["nginx", "-g", "daemon off;"] 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 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": { 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 0e7614a..006f88b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,50 +1,52 @@ <script setup lang="ts"> -import { resourceServer, settings } from '@/main' import { ref } from 'vue' +import { type AxiosInstance } from 'axios' import { useKeycloak } from '@dsb-norge/vue-keycloak-js' -let resource = ref({}) +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' +import ResourceResponse from './ResourceResponse.vue' + 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 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() } -function accountManagemnt() { +function accountManagement() { 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'] + resources.value = resp.data["plugins"] + } + ) +} +getResources() + +async function getResource(evt: MouseEvent, resourceName: string, resource: Resource, resourceProviderId?: string) { + const url = resource.default_resource_id ? `${resourceName}/${resource.default_resource_id}` : resourceName + const axiosClient: AxiosInstance = resourceProviderId ? axiosResourceProviders[resourceProviderId] : resourceServer + await axiosClient.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 { @@ -58,90 +60,41 @@ async function get_resource(evt: MouseEvent) { <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, {{ 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']"> - <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 }} - </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> - <button @click="logout" class="logout">Logout</button> - </div> + <UserInfo></UserInfo> <hr> <div class="content"> - <p>Resources (at {{ settings.resourceServerUrl }}) validated by scope:</p> + <p>These resources are available at this authentication provider:</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> + <ResourceButton v-for="(resource, name) in resources" + :resourceName="name.toString()" + :resourceId="resource.default_resource_id" + :innerText="resource.name" + @getResource="getResource($event, name.toString(), resource)" + > + </ResourceButton> </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> + <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> - </div> - </div> - <div v-if="msg" class="msg resource error">{{ 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">{{ key }}</div> - <div class="value">{{ 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> - </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> - </div> - </div> + <TokenView></TokenView> </div> </div> </template> diff --git a/src/ResourceButton.vue b/src/ResourceButton.vue new file mode 100644 index 0000000..8c787cc --- /dev/null +++ b/src/ResourceButton.vue @@ -0,0 +1,50 @@ +<script setup lang='ts'> +import { ref, type PropType, type ComponentObjectPropsOptions } from 'vue' +import { type AxiosInstance } from 'axios' + +import { resourceServer, axiosResourceProviders } from '@/main' + +interface Props { + resourceName: string, + resourceProviderId?: string | number, + resourceId?: string | null, +} + +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>("") + +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 axiosResourceProvider.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/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..b8287bf --- /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" :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" :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" :innerText="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 f4bc87d..459b1b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,31 @@ 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 axios, { Axios, type AxiosInstance } from 'axios' +import App from '@/App.vue' + +export interface Resource { + name: string + default_resource_id: string + role_required: string + scope_required: string +} + +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 @@ -13,11 +36,17 @@ interface Settings { 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( @@ -43,15 +72,31 @@ 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) { + 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}` @@ -72,27 +117,4 @@ function initializeTokenInterceptor(keycloak: Keycloak) { }) } -async function checkHref(elem: HTMLLinkElement) { - 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) - 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)