JavaScript / Node.js
Sign in with NIA — Vue
Add “Sign in with NIA” to a Vue 3 single-page app with oidc-client-ts and the standard OpenID Connect Authorization Code + PKCE flow.
01Overview
This guide adds “Sign in with NIA” to a Vue 3 single-page app using the standard OpenID Connect Authorization Code flow with PKCE. NIA is a conformant OIDC provider, so you use oidc-client-ts — a framework-agnostic OIDC client that works cleanly with Vue — with no NIA-specific SDK.
Vue runs as a public client: there is no server to hold a secret, so the browser exchanges the authorization code for tokens directly with NIA's token endpoint, protected by PKCE instead of a client secret.
- Flow: Authorization Code + PKCE (S256)
- Tokens: RS256 access & ID tokens, rotating refresh token
- You write: a
userManagermodule and a/callbackroute
02Requirements
- Node.js 18+ and a Vue 3 app scaffolded with Vite
- A NIA account with access to the client dashboard
- A registered OAuth client (
client_idonly — no secret) - Your app's redirect URI registered on that client
- Vue Router installed if you want route guards (recommended)
03Installation
oidc-client-ts is a dependency-free OIDC/OAuth2 client for browser apps. It handles PKCE, token storage, and silent renewal for you.
npm install oidc-client-ts04Required OAuth library
Create a single shared UserManager instance and import it wherever you need auth state. It is configured once, from your env vars.
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
export const userManager = new UserManager({
authority: import.meta.env.VITE_NIA_ISSUER,
client_id: import.meta.env.VITE_NIA_CLIENT_ID,
redirect_uri: window.location.origin + "/callback",
post_logout_redirect_uri: window.location.origin,
response_type: "code",
scope: "openid email profile offline_access",
automaticSilentRenew: true,
userStore: new WebStorageStateStore({ store: window.localStorage }),
});offline_access from the scope if you don't need refresh tokens (see step 13).05Environment Variables
Vite exposes only VITE_-prefixed variables to client code — there are no secrets to protect since this is a public client.
VITE_NIA_ISSUER=https://dev-api.niaauth.com
VITE_NIA_CLIENT_ID=your-client-id/token endpoint directly from the browser, so your app's origin must be added to NIA's CORS allowlist in the dashboard, or the token exchange will fail.06Register OAuth Client
In the NIA dashboard, create the client your Vue app will authenticate as:
- Open Dashboard → Clients → New client
- Client type: Single-page app (public —
token_endpoint_auth_method = none) - Allowed scopes:
openidemailprofile(addoffline_accessfor refresh) - Add your redirect URI (next step) and your app's origin to the CORS allowlist
- Copy the
client_id— there is no secret for public clients
07Redirect URI
NIA matches redirect URIs exactly — scheme, host, port, and path must be identical, with no trailing-slash difference. Register one URI per environment.
# Development (Vite default port)
http://localhost:5173/callback
# Production
https://app.example.com/callbackhttp://localhost:5173/callback/ (trailing slash) will be rejected.08Issuer
The issuer is the single URL every OIDC library needs. Its discovery document advertises all endpoints below.
Issuer: https://dev-api.niaauth.com
Discovery: https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token: https://dev-api.niaauth.com/oauth/token
UserInfo: https://dev-api.niaauth.com/oauth/userinfo
JWKS: https://dev-api.niaauth.com/oauth/jwks.json09Authorization Flow
Call signinRedirect() from a login button to start the flow. oidc-client-ts generates the PKCE code verifier/challenge and state automatically and stores them in sessionStorage before redirecting.
import { userManager } from "@/auth/userManager";
async function login() {
await userManager.signinRedirect();
}10Token Exchange
NIA redirects back to /callback?code=...&state=.... signinRedirectCallback verifies state, sends the stored PKCE verifier, exchanges the code, and validates the ID token — all in one call.
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { userManager } from "@/auth/userManager";
const router = useRouter();
onMounted(async () => {
try {
await userManager.signinRedirectCallback();
router.replace("/");
} catch (err) {
console.error("Sign-in failed", err);
router.replace("/");
}
});
</script>
<template>
<p>Signing you in…</p>
</template>After the callback resolves, read the tokens and profile with getUser():
import { userManager } from "./userManager";
const user = await userManager.getUser();
// user.access_token — send as "Authorization: Bearer <token>" to your API
// user.id_token — validated OIDC identity token
// user.profile — { sub, email, name, ... }11JWKS Validation
NIA signs ID tokens with RS256. During signinRedirectCallback, oidc-client-ts fetches NIA's public keys from the JWKS endpoint (discovered automatically), caches them, and verifies the signature, iss, aud, exp, and nonce. You do not verify manually, and no key material ever leaves the library.
const user = await userManager.getUser();
const claims = user?.profile;
console.log(claims?.iss); // "https://dev-api.niaauth.com"
console.log(claims?.aud); // your client_id
// signature (RS256, via JWKS), exp and nonce are already verified by oidc-client-ts.kid, rotation needs no code change on your side.12Session Management
Subscribe to userManager.events to keep app state in sync with the session, and use a small composable to expose the current user reactively.
import { ref } from "vue";
import type { User } from "oidc-client-ts";
import { userManager } from "./userManager";
export const currentUser = ref<User | null>(null);
userManager.getUser().then((u) => (currentUser.value = u));
userManager.events.addUserLoaded((u) => (currentUser.value = u));
userManager.events.addUserUnloaded(() => (currentUser.value = null));
userManager.events.addAccessTokenExpired(() => (currentUser.value = null));13Refresh Tokens
With offline_access in scope and automaticSilentRenew: true set on the UserManager, oidc-client-ts refreshes the access token in the background before it expires — no code needed beyond the initial config. NIA rotates the refresh token on every use, and the library persists the replacement for you.
userManager.events.addSilentRenewError((err) => {
console.error("Silent renew failed", err);
// Fall back to an interactive sign-in.
userManager.signinRedirect();
});14Logout
Call signoutRedirect() to clear the local session and send the user to NIA's end-session endpoint so they are signed out of NIA too. Register your post_logout_redirect_uri on the client.
import { userManager } from "@/auth/userManager";
async function logout() {
await userManager.signoutRedirect();
}15Deployment
Before going live:
- Register the production redirect URI (HTTPS) on the client
- Add the production origin to NIA's CORS allowlist — required since the token exchange happens in the browser
- Set
VITE_NIA_ISSUER/VITE_NIA_CLIENT_IDas build-time env vars on your host - Serve the app over HTTPS — browsers block third-party redirects and storage access on plain HTTP
- Confirm the client stays a public client (no secret embedded in the bundle)
VITE_NIA_ISSUER=https://dev-api.niaauth.com
VITE_NIA_CLIENT_ID=your-production-client-id16Troubleshooting
- redirect_uri_mismatch — the URI must match the registered one exactly (no trailing slash, correct scheme/port).
- CORS error on /oauth/token — your app's origin isn't in NIA's CORS allowlist; add it in the dashboard.
- invalid_client — the client is configured as confidential in NIA but your app sends no secret; make sure it's registered as a public / SPA client.
- invalid_grant / PKCE failed —
sessionStoragewas cleared between login and callback (e.g. a different tab or a hard reload mid-flow). - mismatching_state — same root cause as above: the stored PKCE/state entry didn't survive the redirect.
- invalid_scope — the scope isn't in the client's
allowed_scopes; add it in the dashboard. - Silent renew fails silently — check that a hidden iframe from your origin is not blocked by CSP
frame-src/frame-ancestorsrules.