@dehwyyy/auth
v2.0.0
Published
dehwyyy auth utilities
Downloads
249
Readme
@dehwyyy/auth
Browser-side OIDC auth client for the Paylonium SPAs. Talks directly to Zitadel using Authorization Code + PKCE (S256). No backend auth proxy.
- ESM only, single runtime dependency:
openapi-fetch. - PKCE via WebCrypto (no extra deps).
- Tokens in
localStorage(accessToken/refreshToken/idToken). - PKCE
verifier/state/returnToinsessionStorage. - Refresh-token rotation with single-flight and cross-tab compare-and-clear.
- Vue Router guard with role checks and an OIDC-callback anti-loop.
Install
bun add @dehwyyy/authBoth @dehwyyy/auth and @dehwyyy/auth/v2 resolve to the same v2 entrypoint.
Configuration
import type { ZitadelConfig } from "@dehwyyy/auth"
const config: ZitadelConfig = {
issuer: "https://core.auth.my-paylonium.com",
clientId: "376852297126373720",
redirectUri: window.location.origin + "/auth/callback",
postLogoutRedirectUri: window.location.origin + "/",
// scopes is optional; this is the default:
// ["openid","profile","email","offline_access",
// "urn:zitadel:iam:org:projects:roles","urn:zitadel:iam:user:metadata"]
}Wiring with createAuth
createAuth is the single wiring entry point. It builds the refresh client, the
authenticated API client (access-token attach + auto-refresh on 401), the
services, and a MiddlewareBuilder you can reuse for your own API clients.
import { createAuth } from "@dehwyyy/auth"
export const auth = createAuth(config)
// auth.authService — Login / HandleCallback / GetMe / Logout
// auth.baseAuthService — Refresh / RequestWithAccessToken
// auth.middlewareBuilder — for wiring your own openapi-fetch clients
// auth.callbackPath — derived from config.redirectUri (e.g. "/auth/callback")Reuse the middleware for your domain API client:
import createClient from "openapi-fetch"
import type { paths } from "./api/schema"
export const api = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
api.use(...auth.middlewareBuilder.buildDefault())Login
// returnTo is optional; pass the path you want to land on after auth.
await auth.authService.Login(router.currentRoute.value.fullPath)
// redirects the browser to Zitadel's /oauth/v2/authorizeCallback page
Mount this on config.redirectUri (e.g. /auth/callback). It performs the
token exchange, fetches the user, and redirects to returnTo.
<script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router"
import { CallbackError } from "@dehwyyy/auth"
import { auth } from "@/auth"
const router = useRouter()
onMounted(async () => {
try {
const { returnTo } = await auth.authService.HandleCallback()
// returnTo is already same-origin/relative-validated by the library
// (a foreign origin is dropped to null), so it is safe to navigate to.
await router.replace(returnTo ?? "/")
} catch (e) {
if (e instanceof CallbackError) {
// e.code: "state_mismatch" | "verifier_missing" | "code_missing" | "exchange_failed"
console.error("auth callback failed:", e.code)
}
await router.replace("/")
}
})
</script>
<template>
<div>Signing you in…</div>
</template>HandleCallback is idempotent per authorization code: a double mount (Vue dev
StrictMode) or an F5 on the callback URL reuses the in-flight/resolved exchange
instead of failing with a state mismatch.
Route guard
import { RouterAuthGuard } from "@dehwyyy/auth"
import { auth } from "@/auth"
const guard = new RouterAuthGuard(auth.authService, {
callbackPath: auth.callbackPath, // required — only this route may carry ?code&state
fallbackPage: "/forbidden",
baseServiceURL: window.location.origin,
onGetMe: (user) => { /* hydrate your store */ },
// cacheTTL is optional, defaults to 15000 (ms)
})
router.beforeEach(guard.Authorized(["ADMIN", "DEV"])) // require one of these roles
// or guard.Authorized() to require only authentication
// or guard.Unauthorized([{ to: "/dashboard", roles: ["MERCHANT"] }]) on public pagesSecurity note: the guard only treats a navigation as the OIDC callback when the
target path equals callbackPath and code+state are present. Appending
?code&state to any other protected route does not bypass the guard.
Logout
await auth.authService.Logout()
// clears tokens and redirects to Zitadel /oidc/v1/end_sessionComposability (without createAuth)
The pieces remain individually exported for custom wiring:
AuthService, BaseAuthService, ClientBuilder, ClientInstance,
MiddlewareBuilder, CreateAuthClientInstance, BaseAuthClientInstance,
RouterAuthGuard, CallbackError, plus types
ZitadelConfig, GetMeResponse, VerboseUserInfo, HandleCallbackResult,
RouteLocation, CallbackErrorCode, Auth.
Footgun: BaseAuthClientInstance is a process-wide singleton
BaseAuthClientInstance(issuer) caches its client on first call — the first
issuer wins for the module's lifetime, and later calls with a different issuer
return the original client. For a single-issuer SPA this is fine. If you need
multiple issuers (or isolated instances in tests), use createAuth instead,
which constructs fresh clients each call.
GetMe response shape
type GetMeResponse = {
user_id: string
roles: string[] // keys of the Zitadel project-roles claim
active: boolean // always true when a userinfo response is returned
info?: { // populated only when verbose
username?: string
email?: string
email_verified?: boolean
name?: string
picture?: string
}
}Development
bun install
bun run gen # regenerate src/v2/pkg/types/schema.d.ts from spec/zitadel-oidc.yaml
bun run build # tsc + tsc-alias (full-path ESM resolution)
bun testThe OIDC surface (/oauth/v2/token, /oidc/v1/userinfo, /oidc/v1/end_session)
is described in spec/zitadel-oidc.yaml; all schema types are generated from it
via openapi-typescript. Do not hand-edit schema.d.ts.
