vue-oidc-context — Specification
| Package | @dlukt/vue-oidc-context |
| Status | Implemented through M6 of PLAN.md — release of v0.1.0 pending (manual E2E checklist in playground/README.md) |
| Target | v0.1.0 |
| License | MIT |
| Reference | react-oidc-context v3, oidc-client-ts v3 |
This document is the API contract and behavior specification. The implementation roadmap lives in PLAN.md.
Contents
- Overview
- Goals and non-goals
- Package and installation
- Public API
- Behavior
- SSR
- Error handling
- Security notes
- Migration from react-oidc-context
- Future work
1. Overview
vue-oidc-context provides OpenID Connect (OIDC) and OAuth 2.0 authentication for Vue 3 applications. It is a deliberate port of react-oidc-context to Vue idioms: the same thin-wrapper philosophy, the same option names, the same lifecycle — but exposed as a Vue plugin, composables, components, and a vue-router guard instead of a React context provider and hooks.
All protocol work — authorization code flow with PKCE, token storage, silent renewal, session monitoring — is delegated to oidc-client-ts. This library owns exactly one concern: bridging a UserManager instance into Vue's reactivity system and component tree.
Design principles:
- Thin wrapper. No token logic, no storage logic, no HTTP. If oidc-client-ts can do it, we delegate.
- Parity first. Every react-oidc-context capability has an equivalent here (or an explicit non-goal). Option names (
onSigninCallback,skipSigninCallback, …) are identical to ease migration and let users reuse the upstream documentation. - Vue-idiomatic.
app.use()plugin as the primary path,provide/injectunder the hood, destructurable refs from composables, navigation guards instead of HOCs. - Fully typed. TypeScript strict; option and return types re-use oidc-client-ts types directly rather than duplicating them.
2. Goals and non-goals
Goals (v1)
- Reactive auth state (
user,isLoading,isAuthenticated,activeNavigator,error) driven byUserManagerevents. - Automatic sign-in callback handling on app startup (redirect and popup flows), with the same hooks react-oidc-context offers.
- Full pass-through of the
UserManagermethod surface (signinRedirect,signinPopup,signinSilent,signinResourceOwnerCredentials,signoutRedirect,signoutPopup,signoutSilent,removeUser,clearStaleState,querySessionStatus,revokeTokens,startSilentRenew,stopSilentRenew). - Plugin (
createOidcAuth) and component (<AuthProvider>) installation styles, sharing one core. - Route protection:
createAuthGuardfor vue-router (optional peer) and an<AuthenticationRequired>component for router-less apps. useAutoSignincomposable.- SSR-safe: importable and installable in Node without touching
window.
Non-goals (v1)
- Nuxt module. A client-plugin recipe is documented (§6); auto-imports and module packaging are future work.
- Vue 2 /
@vue/composition-api. - State-library integration. No pinia dependency; the library's own reactive refs are the store.
- UI components. No login buttons, no styled anything.
- Custom token storage or protocol extensions. Configure oidc-client-ts (
userStore,stateStore, …) instead. withAuthHOC. HOCs are not a Vue idiom;useAuth()and slot props cover the use case.
3. Package and installation
pnpm add @dlukt/vue-oidc-context oidc-client-tsThe unscoped npm name
vue-oidc-contextis owned by an unrelated package; this project publishes under the@dluktscope. The GitHub repository remainsdlukt/vue-oidc-context.
| Peer dependency | Range | Notes |
|---|---|---|
vue | ^3.5.0 | Uses app.onUnmount, onScopeDispose |
oidc-client-ts | ^3.3.0 | Required at runtime |
vue-router | ^4.2.0 || ^5.0.0 | Optional — only needed for the ./router subpath; the guard uses only API identical across both majors |
Entry points (sideEffects: false, ESM + CJS + type declarations):
| Import | Contents |
|---|---|
@dlukt/vue-oidc-context | Core: plugin, composables, components, utils, types |
@dlukt/vue-oidc-context/router | createAuthGuard (kept in a subpath so the core entry's type declarations never reference vue-router) |
4. Public API
4.1 createOidcAuth(options)
Creates the auth instance. Installable as a Vue plugin; also usable directly outside components (router guards, fetch interceptors, main.ts).
import { createApp } from "vue";
import { createOidcAuth } from "@dlukt/vue-oidc-context";
import App from "./App.vue";
const auth = createOidcAuth({
authority: "https://demo.duendesoftware.com",
client_id: "interactive.public",
redirect_uri: `${window.location.origin}/`,
post_logout_redirect_uri: `${window.location.origin}/`,
onSigninCallback: () => {
// strip ?code=...&state=... from the URL after the redirect completes
window.history.replaceState({}, document.title, window.location.pathname);
},
});
createApp(App).use(auth).mount("#app");Signature and types:
export function createOidcAuth(options: OidcAuthOptions): OidcAuth;
/** Lifecycle hooks shared by the plugin and the <AuthProvider> component.
* Names and semantics are identical to react-oidc-context. */
export interface AuthCallbacks {
/** Invoked after the signin redirect/popup callback has been processed.
* Typical use: remove auth params from the URL, restore user state. */
onSigninCallback?: (user: User | undefined) => Promise<void> | void;
/** Skip automatic signinCallback() even when auth params are present
* (e.g. the params belong to a different OAuth integration). Default: false. */
skipSigninCallback?: boolean;
/** Return true when the current URL is the post-logout redirect URI;
* triggers automatic signoutCallback() processing. */
matchSignoutCallback?: (settings: UserManagerSettings) => boolean;
/** Invoked after signoutCallback() has been processed. */
onSignoutCallback?: (
resp: SignoutResponse | undefined,
) => Promise<void> | void;
/** Invoked after removeUser() completes. */
onRemoveUser?: () => Promise<void> | void;
}
/** Either flat UserManagerSettings (the library constructs the UserManager),
* or a caller-supplied UserManager instance. */
export type OidcAuthOptions =
| (UserManagerSettings & AuthCallbacks & { userManager?: undefined })
| (AuthCallbacks & { userManager: UserManager });
export interface OidcAuth extends AuthContext {
/** Vue plugin hook: provides the AuthContext to the app's components. */
install(app: App): void;
/** Resolves once the initialization sequence (§5.1) has settled
* (successfully or with `error` set). Useful before router.isReady(). */
readonly initialized: Promise<void>;
}Rules:
- Options are flat, exactly like react-oidc-context's
AuthProviderProps—UserManagerSettingskeys andAuthCallbackskeys side by side. A migrating user copies their config object unchanged. - If
userManageris given, allUserManagerSettingskeys are rejected by the types; callbacks remain available. - In the browser,
createOidcAuth()creates theUserManagerand starts the async initialization sequence (§5.1) immediately — not oninstall(). This makes the instance fully usable without a Vue app (router guards, interceptors);install()only provides the context to components. - Installing the same instance on a second app is allowed and shares state; installing twice on the same app is a dev-mode warning no-op.
4.2 useAuth()
The primary consumer API. Injects the nearest AuthContext (from the plugin or an enclosing <AuthProvider>).
<script setup lang="ts">
import { useAuth } from "@dlukt/vue-oidc-context";
const {
user,
isAuthenticated,
isLoading,
error,
signinRedirect,
signoutRedirect,
} = useAuth();
</script>
<template>
<div v-if="isLoading">Signing you in/out…</div>
<div v-else-if="error">Oops… {{ error.message }} ({{ error.source }})</div>
<template v-else-if="isAuthenticated">
Hello {{ user?.profile.name }}
<button @click="signoutRedirect()">Log out</button>
</template>
<button v-else @click="signinRedirect()">Log in</button>
</template>Signature and types:
/** Throws with a descriptive message if no plugin/provider is installed above. */
export function useAuth(): AuthContext;
export type NavigatorKey =
| "signinRedirect"
| "signinResourceOwnerCredentials"
| "signinPopup"
| "signinSilent"
| "signoutRedirect"
| "signoutPopup"
| "signoutSilent";
/** Reactive slice of the context. All refs are read-only for consumers. */
export interface AuthState {
/** undefined = initialization not finished; null = no session; User = session.
* ShallowRef: User is a class instance, its internals are not made reactive. */
user: Readonly<ShallowRef<User | null | undefined>>;
/** True until initialization settles, and while any navigator method is in flight. */
isLoading: Readonly<Ref<boolean>>;
/** True while a non-expired user is loaded (§5.3 for exact semantics). */
isAuthenticated: ComputedRef<boolean>;
/** The signin/signout method currently in flight, if any. */
activeNavigator: Readonly<Ref<NavigatorKey | undefined>>;
/** Last initialization/renewal/navigation error (§7). Cleared on next user load. */
error: Readonly<ShallowRef<ErrorContext | undefined>>;
}
export interface AuthContext extends AuthState {
/** Settings of the underlying UserManager. */
readonly settings: UserManagerSettings;
/** Raw oidc-client-ts event bus (addAccessTokenExpiring, addUserLoaded, …). */
readonly events: UserManagerEvents;
// navigator methods — wrapped, they drive activeNavigator/isLoading/error (§5.4)
signinRedirect(args?: SigninRedirectArgs): Promise<void>;
signinPopup(args?: SigninPopupArgs): Promise<User>;
signinSilent(args?: SigninSilentArgs): Promise<User | null>;
signinResourceOwnerCredentials(
args: SigninResourceOwnerCredentialsArgs,
): Promise<User>;
signoutRedirect(args?: SignoutRedirectArgs): Promise<void>;
signoutPopup(args?: SignoutPopupArgs): Promise<void>;
signoutSilent(args?: SignoutSilentArgs): Promise<void>;
// pass-through methods — bound to the UserManager
removeUser(): Promise<void>; // also invokes onRemoveUser
clearStaleState(): Promise<void>;
querySessionStatus(
args?: QuerySessionStatusArgs,
): Promise<SessionStatus | null>;
revokeTokens(types?: RevokeTokensTypes): Promise<void>;
startSilentRenew(): void;
stopSilentRenew(): void;
}Reactivity gotcha.
AuthContextis a plain object holding refs (VueUse convention): destructure it —const { user } = useAuth()— and refs auto-unwrap in templates. If you keep the whole object (const auth = useAuth()), nested refs do not unwrap in templates: writeauth.user.valuein script andauth.user?.valuein templates, or destructure instead.
4.3 <AuthProvider>
Component-scoped alternative to the plugin, for nested contexts and multi-IdP/multi-tenant apps. Renderless; provide()s an AuthContext to its subtree, shadowing any plugin-provided one.
<script setup lang="ts">
import { AuthProvider } from "@dlukt/vue-oidc-context";
import { tenantSettings } from "./tenants";
</script>
<template>
<AuthProvider :settings="tenantSettings" :on-signin-callback="stripUrl">
<TenantAdminArea />
</AuthProvider>
</template>Props and slots:
export interface AuthProviderProps extends AuthCallbacks {
/** UserManagerSettings as a single object prop (templates can't spread 40 flat props). */
settings?: UserManagerSettings;
/** Bring-your-own UserManager. Exactly one of settings/userManager must be set. */
userManager?: UserManager;
}
/** Slot props: the AuthContext with its refs unwrapped to plain values. */
export type AuthProviderSlotProps = Omit<AuthContext, keyof AuthState> & {
user: User | null | undefined;
isLoading: boolean;
isAuthenticated: boolean;
activeNavigator: NavigatorKey | undefined;
error: ErrorContext | undefined;
};- Default slot receives the context as slot props with the refs unwrapped to plain values (
AuthProviderSlotProps):<AuthProvider v-slot="{ isAuthenticated }">…. Slot props are not top-level template bindings, so Vue would not auto-unwrap refs there; the component unwraps them per render instead. Inside the subtree,useAuth()returns the usual ref-basedAuthContext. - Exactly one of
settings/userManagermust be set; setup throws otherwise. Props are read once when the provider is created — changing them later has no effect (parity: react-oidc-context fixes its UserManager on first render). - Initialization (§5.1) starts during component setup (in the browser); event subscriptions are disposed with the component's effect scope on unmount.
- Nesting is supported;
useAuth()resolves the nearest provider. Two providers pointing at the sameauthority+client_idshare oidc-client-ts storage keys — that is oidc-client-ts behavior, not something this library changes. - The plugin and the component produce the same
AuthContextshape from the same core; the only difference is scope and lifetime.
4.4 useAutoSignin(options?)
Automatically attempts sign-in once per context (plugin instance or enclosing <AuthProvider>), matching react-oidc-context's useAutoSignin. Several components may call the composable; only one attempt is made through a given context.
export interface UseAutoSigninOptions {
/** Default: "signinRedirect". signinResourceOwnerCredentials is not supported. */
signinMethod?: "signinRedirect" | "signinPopup";
signinArgs?: SigninRedirectArgs | SigninPopupArgs;
}
export function useAutoSignin(options?: UseAutoSigninOptions): {
isAuthenticated: AuthState["isAuthenticated"];
isLoading: AuthState["isLoading"];
error: AuthState["error"];
};The attempt fires only when all hold: no auth params in the URL, !isAuthenticated, no activeNavigator, !isLoading, and no previous attempt was made through this context. Implemented with a watcher so it also fires if the conditions become true after initialization settles. A failed attempt is not retried; the failure surfaces on error per §5.4 (the composable swallows the rejection, so no unhandled-rejection noise).
4.5 createAuthGuard(auth, options?) — @dlukt/vue-oidc-context/router
vue-router navigation guard; the Vue-idiomatic equivalent of withAuthenticationRequired.
import { createRouter, createWebHistory } from "vue-router";
import { createAuthGuard } from "@dlukt/vue-oidc-context/router";
import { auth } from "./auth"; // the createOidcAuth(...) instance
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: Home },
{ path: "/admin", component: Admin, meta: { requiresAuth: true } },
],
});
router.beforeEach(createAuthGuard(auth));Signature:
export interface AuthGuardOptions {
/** Which routes to protect. Default: (to) => to.meta.requiresAuth === true */
shouldProtect?: (to: RouteLocationNormalized) => boolean;
/** Args passed to signinRedirect for unauthenticated users.
* Default: { state: { returnTo: to.fullPath } } */
signinArgs?:
SigninRedirectArgs | ((to: RouteLocationNormalized) => SigninRedirectArgs);
}
export function createAuthGuard(
auth: OidcAuth,
options?: AuthGuardOptions,
): NavigationGuard;Behavior:
- If
shouldProtect(to)is false → allow navigation. - Await
auth.initialized(so a hard refresh on a protected route waits for the session lookup / signin callback). - If
isAuthenticated→ allow. - Otherwise call
auth.signinRedirect(signinArgs)and cancel the navigation (return false). IfsigninRedirectrejects, the failure lands onerror(§5.4) and the navigation is still cancelled.
The guard takes the OidcAuth instance explicitly — it never relies on inject()/runWithContext, so it works regardless of registration order or router version details.
Returning to the original route is the app's choice via onSigninCallback (the default signinArgs carry the path):
onSigninCallback: (user) => {
const returnTo = (user?.state as { returnTo?: string } | undefined)?.returnTo ?? "/";
void router.replace(returnTo); // deliberately not awaited — see below
},Two pitfalls when restoring the route (found during playground verification, PLAN M5):
- Do not
awaitrouter navigation insideonSigninCallback. The guard on the target route awaitsauth.initialized, andinitializedresolves only afteronSigninCallbackreturns (§5.1) — awaiting the navigation deadlocks both. Fire-and-forget (void router.replace(...)) is safe: the guard proceeds as soon as initialization settles. window.history.replaceStatealone is not enough in a vue-router app. The router usually has already resolved its initial navigation against the callback URL and does not observereplaceState, so the rendered route would not match the restored URL. Userouter.replacethere;replaceStateis for router-less apps.
Recommended RouteMeta augmentation (documented, not shipped):
declare module "vue-router" {
interface RouteMeta {
requiresAuth?: boolean;
}
}4.6 <AuthenticationRequired>
withAuthenticationRequired equivalent for apps not using vue-router (or for protecting a subtree rather than a route).
<AuthenticationRequired>
<Dashboard />
<template #redirecting>
<p>Redirecting to sign-in…</p>
</template>
</AuthenticationRequired>export interface AuthenticationRequiredProps {
/** Default: "signinRedirect" */
signinMethod?: "signinRedirect" | "signinPopup";
signinArgs?: SigninRedirectArgs | SigninPopupArgs;
}Behavior: renders the default slot while isAuthenticated; otherwise renders the #redirecting slot (default: nothing) and — once !isLoading, no activeNavigator, and no auth params are in the URL — invokes the signin method a single time per component instance. A failed attempt is not retried; the failure surfaces on error per §5.4.
4.7 hasAuthParams(location?)
Re-implementation of the react-oidc-context util, exported for userland checks.
/** True when the URL carries an OIDC authorization response:
* (code | error) + state in the query string (response_mode "query")
* or in the fragment (response_mode "fragment"). */
export function hasAuthParams(location?: Location): boolean; // default: window.location4.8 Advanced: injection key
/** The InjectionKey used by the plugin and <AuthProvider>. For building custom
* providers or accessing the context from libraries. */
export const AUTH_CONTEXT_KEY: InjectionKey<AuthContext>;4.9 Exports summary
From @dlukt/vue-oidc-context:
| Export | Kind |
|---|---|
createOidcAuth | function |
useAuth, useAutoSignin | composables |
AuthProvider, AuthenticationRequired | components |
hasAuthParams | util |
AUTH_CONTEXT_KEY | injection key |
AuthState, AuthContext, AuthCallbacks, OidcAuthOptions, OidcAuth, AuthProviderProps, AuthProviderSlotProps, AuthenticationRequiredProps, UseAutoSigninOptions, NavigatorKey, ErrorContext | types |
User, UserManager, WebStorageStateStore, InMemoryWebStorage, Log and the settings/args types used above | re-exports from oidc-client-ts (convenience) |
From @dlukt/vue-oidc-context/router: createAuthGuard, AuthGuardOptions.
5. Behavior
5.1 Initialization sequence
Runs once per context (plugin creation / provider mount), asynchronously, in the browser only. Mirrors react-oidc-context exactly:
- If
hasAuthParams()and notskipSigninCallback: a.user = await userManager.signinCallback()b.await onSigninCallback?.(user) - If no user yet:
user = await userManager.getUser() - Commit:
userref set (nullif none),isLoading→false,errorcleared. On failure:errorset withsource: "signinCallback",isLoading→false. - Independently: if
matchSignoutCallback?.(settings)returns true: a.resp = await userManager.signoutCallback()b.await onSignoutCallback?.(resp)On failure:errorset withsource: "signoutCallback". initializedresolves (never rejects; failures are surfaced viaerror).
Note (parity): the library does not clean auth params from the URL. That is the app's job in onSigninCallback — same as react-oidc-context.
5.2 Event subscriptions
On initialization the context subscribes to four UserManagerEvents; all are unsubscribed on teardown (§5.5):
| Event | Effect on state |
|---|---|
addUserLoaded | user ← loaded user, error cleared |
addUserUnloaded | user ← null |
addUserSignedOut | signed-out flag set: isAuthenticated becomes false, user is retained (parity with react-oidc-context) |
addSilentRenewError | error ← error with source: "renewSilent" |
Any further events (addAccessTokenExpiring, …) are the app's business via auth.events.
5.3 State semantics
| Field | Semantics |
|---|---|
user | undefined before init settles → User or null afterwards; updated by events |
isLoading | true initially; false once init settles; true again while a navigator method is in flight |
isAuthenticated | !!user && !user.expired && !signedOut — a computed re-evaluated when user or the signed-out flag changes. Not time-reactive: an access token expiring does not by itself flip it (same as react-oidc-context). Gate API calls on user.access_token/events, not on this flag. |
activeNavigator | Set to the method name for the duration of any wrapped navigator call |
error | Set by init failures, navigator failures, and silent-renew errors; cleared when a user is loaded |
The signed-out flag is reset whenever a user is loaded (sign-in after sign-out works).
5.4 Navigator method wrapping
Every signin*/signout* method on the context wraps the corresponding UserManager method:
activeNavigator← method name;isLoading←true.- Await the underlying call.
userupdates arrive viaaddUserLoaded/addUserUnloaded, not from the return value. - On rejection:
error←ErrorContextwithsource= method name. The wrapped promise still rejects (callers maytry/catch); the ref is for template consumption. - Finally:
activeNavigator←undefined;isLoading←false.
removeUser, clearStaleState, querySessionStatus, revokeTokens, startSilentRenew, stopSilentRenew are plain bound pass-throughs (no state choreography), except removeUser which additionally awaits onRemoveUser.
5.5 Lifetime and teardown
- Plugin: subscriptions live for the app's lifetime; disposed via
app.onUnmount(). <AuthProvider>: disposed with the component's effect scope (onScopeDispose).- Teardown unsubscribes events and stops nothing else — in particular it does not call
stopSilentRenew()or clear storage (parity: react-oidc-context also only unsubscribes). - One
OidcAuthinstance owns oneUserManager. Multiple instances on one page are supported (multi-tenant); storage isolation follows oidc-client-ts key derivation (per authority + client).
6. SSR
- Importing any entry point is side-effect free and never touches
window/document. - On the server,
createOidcAuthcreates an inert context:userstaysundefined,isLoadingstaystrue,initializedstays pending, and navigator/pass-through methods reject with a descriptive"…is only available in a browser"error (react-oidc-context behaves the same way with its unsupported-environment stubs). - Environment detection:
typeof window !== "undefined", evaluated at construction time, not import time. - Nuxt recipe (documented in the guide, not shipped): register the plugin inside a
.client.tsNuxt plugin; render auth-dependent UI inside<ClientOnly>or gate onisLoading.
7. Error handling
ErrorContext mirrors react-oidc-context:
export type ErrorSource =
| "signinCallback"
| "signoutCallback"
| "renewSilent"
| NavigatorKey
| "unknown";
export type ErrorContext = Error & {
/** Which operation produced the error. */
source: ErrorSource;
/** Original thrown value when it was not an Error instance. */
innerError?: unknown;
};Normalization rule: thrown Error instances are tagged with source; non-Error values are wrapped in an Error with the original value preserved on innerError.
useAuth() outside any provider throws synchronously: "useAuth() requires the auth plugin (app.use(createOidcAuth(...))) or an enclosing <AuthProvider>."
8. Security notes
- oidc-client-ts defaults apply: authorization code flow + PKCE; tokens in
sessionStorage(configurable viauserStore, e.g.WebStorageStateStoreoverlocalStorage, orInMemoryWebStorage). - This library never reads, stores, or logs tokens itself; it only holds the
Userobject oidc-client-ts hands it, in a non-persistedshallowRef. - Redirect-URI cleanup (
onSigninCallback) is the app's responsibility; the docs show the canonicalhistory.replaceStatesnippet.
9. Migration from react-oidc-context
| react-oidc-context | @dlukt/vue-oidc-context |
|---|---|
<AuthProvider {...oidcConfig}> (app root) | app.use(createOidcAuth(oidcConfig)) — same flat config object |
<AuthProvider> (nested / multi-IdP) | <AuthProvider :settings="…"> component |
const auth = useAuth() | const { user, isAuthenticated, … } = useAuth() — destructure; fields are refs |
auth.isLoading (plain boolean) | isLoading.value in script, isLoading in templates |
withAuthenticationRequired(Component, opts) | createAuthGuard(auth) + meta.requiresAuth (router apps) or <AuthenticationRequired> (component tree) |
withAuth(Component) | not ported — use useAuth() / provider slot props |
useAutoSignin(opts) | useAutoSignin(opts) — identical |
hasAuthParams() | hasAuthParams() — identical |
AuthContext (React context object) | AUTH_CONTEXT_KEY (Vue injection key) |
onSigninCallback / skipSigninCallback / matchSignoutCallback / onSignoutCallback / onRemoveUser | identical names and semantics |
10. Future work
Explicitly out of scope for v1, tracked as candidates:
- Nuxt module (auto-registration,
useAuthauto-import, route-middleware sugar). - Time-reactive
isAuthenticated(opt-in timer tied toexpires_at). - Devtools integration (Vue devtools custom inspector for auth state).
useAuthenticatedFetch/ interceptor helpers.