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: : + - 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 17d7c4c..8d4804f 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; + text-align: center; } 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; @@ -20,6 +24,9 @@ hr { .center { text-align: center; } +.error { + color: darkred; +} .content { width: 100%; display: flex; @@ -51,7 +58,6 @@ hr { border: 2px solid darkkhaki; padding: 3px 6px; text-decoration: none; - text-align: center; color: black; } .user-info a.logout:hover { @@ -66,7 +72,6 @@ hr { margin: 0; } .debug-auth p { - text-align: center; border-bottom: 1px solid black; } .debug-auth ul { @@ -97,15 +102,25 @@ hr { .hasResponseStatus.status-503 { background-color: #ffA88050; } -.role { + +.role, .scope { padding: 3px 6px; + margin: 3px; + border-radius: 6px; +} + +.role { background-color: #44228840; } +.scope { + background-color: #8888FF80; +} + + /* For home */ .login-box { - text-align: center; background-color: antiquewhite; margin: 0.5em auto; width: fit-content; @@ -132,7 +147,6 @@ hr { max-height: 2em; } .providers .provider .link div { - text-align: center; background-color: #f7c7867d; border-radius: 8px; padding: 6px; @@ -147,22 +161,25 @@ 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; } -.content .links-to-check a { +.content .links-to-check button { color: black; padding: 5px 10px; text-decoration: none; border-radius: 8px; + border: none; + cursor: pointer; +} +.content .links-to-check span { + margin: auto; } .token { @@ -170,12 +187,6 @@ hr { font-family: monospace; } -.actions { - display: flex; - justify-content: center; - gap: 0.5em; -} - .resource { padding: 0.5em; display: flex; @@ -184,8 +195,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; } @@ -197,11 +208,7 @@ hr { text-align: center; } -.error { - color: darkred; -} - -.from-keycloak-vue { +.token-info { margin: 0 1em; } diff --git a/src/App.vue b/src/App.vue index fc161f9..006f88b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,40 +1,58 @@ @@ -42,70 +60,41 @@ async function get_resource(id: string) { 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + diff --git a/src/main.ts b/src/main.ts index 427388e..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,12 +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( @@ -44,14 +72,31 @@ axios.get("settings.json").then().then( }, onReady(keycloak: Keycloak) { initializeTokenInterceptor(keycloak) + 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}`