Skip to main content

Securing Vue Apps with Keycloak

· 2 min read
Jeff Patzer
Phase Two

In this article we'll be using Keycloak to quickly secure a Vue application with user management and single sign on (SSO) using the open source IAMs Keycloak for Authentication and Authorization. We will demonstrate the integration by securing a page for logged-in users. This quickly provides a jump-off point to more complex integrations.

info

If you just want to skip to the code, visit the Phase Two Vue example.

Setting up a Keycloak Instance​

Instructions
tip

If you already have a Keycloak instance, skip to the next section.

You can run Keycloak on your machine with Docker, or use a hosted Phase Two cluster. Both come with Phase Two's Keycloak extensions.

Run Keycloak locally​

The Phase Two examples repo includes a local Phase Two Keycloak that is already set up for the examples: a p2examples realm with a client for each example and a demo user. You need Docker with the Compose plugin.

git clone https://github.com/p2-inc/examples.git
cd examples
docker compose -f keycloak/docker-compose.yml up -d --wait
WhatValue
Issuer URLhttp://localhost:8080/auth/realms/p2examples
Admin consolelocalhost:8080/auth/admin, admin / admin
Demo userdemo / demo

The client and the user from the next two sections already exist in this realm, so you can skip both. The local Keycloak README lists the client of each example. docker compose -f keycloak/docker-compose.yml down stops Keycloak, and the next up starts again from a fresh realm.

Use a hosted Phase Two cluster​

  1. Sign up on the Phase Two Dashboard with an email address, a GitHub account or a Google account.
  2. Create a Starter cluster, which is free for 30 days, and add a realm to it.
  3. Click Open Console to open the realm in the Keycloak admin console.

The app connects to Keycloak through the realm's issuer URL, https://<your-keycloak-host>/auth/realms/<your-realm>. In the admin console, Realm settings > General > Endpoints > OpenID Endpoint Configuration shows it as issuer.

Keep the admin console open: the next sections create the app's client and a user there.

Setting up an OIDC Client​

Instructions

The app needs an OpenID Connect client in Keycloak. The app runs in the browser, where it can't keep a secret, so it gets a public client and logs users in with the authorization code flow and PKCE. Keycloak's docs describe all the client settings.

  1. Open the Keycloak admin console and select your realm.

  2. Click Clients in the menu.

  3. Click Create client.

  4. Leave Client type set to OpenID Connect.

  5. Enter a Client ID. The app sends this ID in its OpenID Connect requests.

  6. Supply a Name for the client.

  7. Click Next.

    Keycloak OIDC Create Client General Settings

  8. Under Capability config:

    • Leave Client authentication off.
    • Leave Standard flow checked and Direct access grants unchecked. The app logs users in through Keycloak's login page, so it never needs their password.
    • Turn Require PKCE on, and leave PKCE Method set to S256.
  9. Click Next.

    Keycloak OIDC Create Client Capability Config

  10. Under Login settings, enter the app's URLs. For an app running on http://localhost:3000:

    Valid redirect URIs (where Keycloak may send users back after they log in)

    http://localhost:3000/*

    Valid post logout redirect URIs (where Keycloak may send users back after they log out; + means the valid redirect URIs)

    +

    Web origins (the origins allowed to call Keycloak from the browser, for example to get tokens; + means the origins of the valid redirect URIs)

    +
    URI and Origin Details

    Use the port of the app you run: most examples run on port 3000, and the Angular examples on 4200. For an app deployed somewhere, use its URL instead of localhost.

  11. Click Save.

    Keycloak OIDC Create Client Login Settings

OIDC Config​

The app needs two values from Keycloak:

  • Issuer URL: the realm's URL, such as http://localhost:8080/auth/realms/p2examples for the local Keycloak, or https://<your-keycloak-host>/auth/realms/<your-realm>. Realm settings > General > Endpoints > OpenID Endpoint Configuration shows it as issuer.
  • Client ID: the ID you entered when you created the client.

Adding a Non-Admin User​

Instructions
tip

It is bad practice to use your admin user to sign in to an application.

The local Keycloak from the examples repo already has a non-admin user, demo / demo. On any other Keycloak, add one:

  1. Open the Keycloak admin console and select your realm.
  2. Click Users in the menu.
  3. Click Add user.
  4. Fill out the Username, Email, First name and Last name. Click Create.
  5. Open the Credentials tab and click Set password. Enter a password for the user. For this tutorial, you can turn Temporary off.
  6. Click Save, then confirm with Save password.

Setting up a Vue.js Project​

info

We will use the Phase Two Vue example code here, but the logic could easily be applied to any existing application.

This example uses Vue.js 3 with Vite and Vue Router. We're going to leverage oidc-client-ts to integrate OIDC authentication with the Vue app. The oidc-client-ts package is a well-maintained and used library. It provides a lot of utilities for building out a fully production app.

  1. Clone the Phase Two example repo.

  2. Open the Vue folder within /frameworks/vue. You need Node.js 24 and pnpm.

  3. Point the app at your Keycloak. The app reads the issuer URL of your realm from VITE_OIDC_ISSUER_URI and the client ID from VITE_OIDC_CLIENT_ID. .env sets them to the hosted Phase Two demo realm. To use the local Keycloak from the examples repo, copy the sample file:

    cp .env.local.sample .env.local

    Vite loads .env.local over .env, so the app now uses the local Keycloak and its vue-example client:

    VITE_OIDC_ISSUER_URI=http://localhost:8080/auth/realms/p2examples
    VITE_OIDC_CLIENT_ID=vue-example

    For another Keycloak, create .env.local the same way, and set VITE_OIDC_ISSUER_URI to your realm's issuer URL and VITE_OIDC_CLIENT_ID to the ID of your client. The app runs in the browser, so it uses a public client and no client secret. The client needs http://localhost:3000/* as valid redirect URI, and + as web origin and as valid post logout redirect URI.

  4. Run pnpm install and then pnpm dev. The app runs on localhost:3000.

  5. We'll review where we configure the OIDC client. Open src/auth.ts. It creates the oidc-client-ts UserManager from the environment variables:

    import { UserManager } from 'oidc-client-ts'

    export const userManager = new UserManager({
    authority: import.meta.env.VITE_OIDC_ISSUER_URI,
    client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
    redirect_uri: `${window.location.origin}/auth`,
    silent_redirect_uri: `${window.location.origin}/silent-refresh`,
    post_logout_redirect_uri: `${window.location.origin}/`,
    scope: 'openid profile email',
    })

    oidc-client-ts logs users in with the authorization code flow and PKCE. It keeps the tokens in session storage and renews them before they expire.

  6. With the UserManager defined, we expose it to the components through a composable. Switch to src/composables/useAuth.ts. It keeps the signed-in user in reactive state, which the UserManager events update, and useAuth() returns that state with the signIn and signOut actions:

    import type { User } from 'oidc-client-ts'
    import { shallowRef } from 'vue'
    import { userManager } from '@/auth'

    const user = shallowRef<User | null>(null)
    const error = shallowRef<Error | null>(null)

    userManager.events.addUserLoaded((loadedUser) => {
    user.value = loadedUser
    error.value = null
    })
    userManager.events.addUserUnloaded(() => {
    user.value = null
    })
    userManager.events.addSilentRenewError((renewError) => {
    error.value = renewError
    })

    export async function initAuth() {
    const storedUser = await userManager.getUser()
    user.value = storedUser && !storedUser.expired ? storedUser : null
    }

    export function setAuthError(authError: unknown) {
    error.value = authError instanceof Error ? authError : new Error(String(authError))
    }

    export function useAuth() {
    return {
    user,
    error,
    signIn: () => userManager.signinRedirect({ state: { returnTo: window.location.pathname } }),
    signOut: () => userManager.signoutRedirect(),
    }
    }

    signIn sends the current path along with the login, so the app can bring the user back to it. signOut also ends the Keycloak session.

    src/main.ts loads the stored user with initAuth() before it mounts the app:

    import './assets/main.css'

    import { createApp } from 'vue'
    import App from './App.vue'
    import { initAuth } from './composables/useAuth'
    import router from './router'

    await initAuth()

    createApp(App).use(router).mount('#app')
  7. There are a few routes in play here that the library leverages during the login. src/router/index.ts adds /auth and /silent-refresh next to the home page:

    import { createRouter, createWebHistory } from 'vue-router'
    import AuthCallbackView from '@/views/AuthCallbackView.vue'
    import HomeView from '@/views/HomeView.vue'
    import SilentRefreshView from '@/views/SilentRefreshView.vue'

    const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
    { path: '/', name: 'home', component: HomeView },
    { path: '/auth', name: 'auth-callback', component: AuthCallbackView },
    { path: '/silent-refresh', name: 'silent-refresh', component: SilentRefreshView },
    ],
    })

    export default router

    Keycloak redirects the user back to /auth after the login. src/views/AuthCallbackView.vue completes the login and returns to the page it started from:

    <script setup lang="ts">
    import { onMounted } from 'vue'
    import { useRouter } from 'vue-router'
    import { userManager } from '@/auth'
    import { setAuthError } from '@/composables/useAuth'

    const router = useRouter()

    onMounted(async () => {
    try {
    const user = await userManager.signinCallback()
    const state = user?.state as { returnTo?: string } | undefined
    await router.replace(state?.returnTo ?? '/')
    } catch (callbackError) {
    setAuthError(callbackError)
    await router.replace('/')
    }
    })
    </script>

    src/views/SilentRefreshView.vue handles silent renewals on /silent-refresh:

    <script setup lang="ts">
    import { onMounted } from 'vue'
    import { userManager } from '@/auth'

    onMounted(() => userManager.signinSilentCallback())
    </script>
  8. Now that we have all the things set up, the src/components/UserStatus.vue component reads the user's state with useAuth() and displays the appropriate UI:

    <script setup lang="ts">
    import TokenPanels from '@/components/TokenPanels.vue'
    import { useAuth } from '@/composables/useAuth'

    const { user, error, signIn, signOut } = useAuth()

    const buttonClasses =
    'cursor-pointer rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
    </script>

    <template>
    <div>
    <div class="pb-8 text-xl italic">Your current status is:</div>
    <template v-if="user">
    <div class="mb-2 text-2xl text-p2blue-700">Authenticated</div>
    <div class="mb-6 text-p2blue-700">
    <div>{{ user.profile.name }}</div>
    <div>{{ user.profile.email }}</div>
    </div>
    <button type="button" :class="buttonClasses" @click="signOut()">Log out</button>
    <TokenPanels :user="user" />
    </template>
    <template v-else-if="error">
    <div class="mb-2 text-2xl text-p2blue-700">Authentication error.</div>
    <div class="mb-6">{{ error.message }}</div>
    <button type="button" :class="buttonClasses" @click="signIn()">Log in</button>
    </template>
    <template v-else>
    <div class="mb-6 text-2xl text-p2blue-700">Not authenticated.</div>
    <button type="button" :class="buttonClasses" @click="signIn()">Log in</button>
    </template>
    </div>
    </template>

    With this, the user object is now easily available. A simple v-if="user" allows the app to determine what UI to show. src/components/TokenPanels.vue decodes the access and ID tokens with jwt-decode and shows their claims.

Learning more​

Phase Two's enhanced Keycloak provides many ways to quickly control and tweak the log in and user management experience. Our blog has many use cases from customizing login pages, setting up magic links (passwordless sign in), and Organization workflows.