LumenJS ships with built-in authentication supporting OIDC providers (Keycloak, Auth0, Google) and native email/password auth. Multi-provider configs share a single user base with automatic account linking. Sessions use encrypted cookies by default, with optional bearer tokens for mobile and API clients.
1. Enable the auth integration in your config:
// lumenjs.config.ts export default { title: 'My App', integrations: ['auth'], };
2. Create lumenjs.auth.ts at the project root with your provider config:
// lumenjs.auth.ts export default { providers: [ { type: 'native', name: 'native', minPasswordLength: 8, allowRegistration: true, }, ], session: { secret: process.env.AUTH_SECRET, // min 32 chars, required maxAge: 604800, // 7 days (seconds) }, };
session.secret is the only required field. It encrypts all session cookies using AES-256-GCM. Generate one with openssl rand -base64 32.
That's it. The framework auto-creates the users table in SQLite, registers all /__nk_auth/* routes, and injects the session middleware. No additional packages needed.
Native auth stores users in a _nk_auth_users SQLite table. Passwords are hashed with scrypt (Node.js built-in, N=16384, 64-byte key). No external dependencies.
Sign up:
curl -X POST http://localhost:3000/__nk_auth/signup -H "Content-Type: application/json" -d '{"email": "[email protected]", "password": "s3cret!!", "name": "Alice"}' // Response (201) { "user": { "sub": "a1b2c3...", "email": "[email protected]", "name": "Alice", "roles": [] } }
Log in:
curl -X POST http://localhost:3000/__nk_auth/login -H "Content-Type: application/json" -H "Accept: application/json" -d '{"email": "[email protected]", "password": "s3cret!!"}' // Response (200) - Set-Cookie header with encrypted session { "user": { "sub": "a1b2c3...", "email": "[email protected]", ... }, "returnTo": "/" }
Without the Accept: application/json header, login returns a 302 redirect to routes.postLogin (default: /).
Configure any OIDC-compliant provider by specifying its issuer URL and client credentials:
// lumenjs.auth.ts export default { providers: [ { type: 'oidc', name: 'keycloak', issuer: 'https://auth.example.com/realms/myapp', clientId: 'my-client-id', clientSecret: process.env.KC_SECRET, scopes: ['openid', 'profile', 'email'], }, ], session: { secret: process.env.AUTH_SECRET }, };
The login flow uses Authorization Code + PKCE:
/__nk_auth/login (or /__nk_auth/login/keycloak for a specific provider)code_verifier, stores it in an encrypted short-lived cookie, and redirects to the provider/__nk_auth/callbackid_token. No separate /userinfo call neededAccount linking: when both OIDC and native providers are configured, OIDC users are automatically linked to existing native accounts by matching email address. Roles from both sources are merged.
.well-known/openid-configuration URL. You only need to provide the issuer. Authorization, token, and end-session endpoints are resolved automatically.
Combine native auth with one or more OIDC providers. All providers share the same user base via automatic email-based account linking:
// lumenjs.auth.ts export default { providers: [ { type: 'native', name: 'native', minPasswordLength: 10, allowRegistration: true, requireEmailVerification: true, }, { type: 'oidc', name: 'google', issuer: 'https://accounts.google.com', clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }, { type: 'oidc', name: 'keycloak', issuer: 'https://auth.example.com/realms/myapp', clientId: process.env.KC_CLIENT_ID, clientSecret: process.env.KC_SECRET, }, ], session: { secret: process.env.AUTH_SECRET, secure: true, }, routes: { loginPage: '/auth/login', postLogin: '/dashboard', }, };
To initiate login with a specific provider, use the provider name in the URL:
<!-- In your login page --> <a href="/__nk_auth/login/google">Sign in with Google</a> <a href="/__nk_auth/login/keycloak">Sign in with Keycloak</a>
A GET to /__nk_auth/login (without a provider name) returns JSON listing all available providers if no default OIDC provider is found:
{ "providers": [
{ "type": "native", "name": "native" },
{ "type": "oidc", "name": "google" },
{ "type": "oidc", "name": "keycloak" }
],
"nativeLogin": "/__nk_auth/login (POST)",
"signup": "/__nk_auth/signup"
}
Protect pages by exporting an auth constant. The framework checks it before rendering the page. Unauthenticated users are redirected to routes.loginPage.
Require any authenticated user:
// pages/dashboard.ts export const auth = true; export class Dashboard extends LitElement { render() { return html`<h1>Dashboard</h1>`; } }
Require specific roles:
// pages/admin/settings.ts export const auth = { roles: ['admin'] }; export class AdminSettings extends LitElement { render() { return html`<h1>Admin Settings</h1>`; } }
How guards work:
export const auth = true: user must be logged in, any role acceptedexport const auth = { roles: ['admin'] }: user must be logged in AND have the admin role, otherwise returns 403 Forbiddenauth export: page is public (unless guards.defaultAuth is true in config)auth export is stripped from client bundles automatically. It only runs on the server. A client-side __nk_has_auth flag is injected so the router knows the page requires auth.
To require authentication for all pages by default (opt-out instead of opt-in), set guards.defaultAuth: true in your auth config. Individual pages can then opt out by not exporting auth.
The authenticated user is available as context.user in every loader, subscribe function, and API route. Even on public pages:
// pages/profile.ts export const auth = true; export function loader({ user }) { // user is guaranteed non-null here because of the auth guard return { name: user.name, email: user.email, roles: user.roles, provider: user.provider, }; } export class Profile extends LitElement { static properties = { name: { type: String } }; render() { return html`<p>Welcome, ${this.name}</p>`; } }
On public pages (no auth export), user is null for anonymous visitors and populated for logged-in users:
// pages/index.ts - public page export function loader({ user }) { return { greeting: user ? `Hello, ${user.name}` : 'Hello, visitor', }; }
The user object shape (AuthUser):
| Field | Type | Description |
|---|---|---|
sub | string | Unique user ID (UUID for native, subject claim for OIDC) |
email | string? | User's email address |
name | string? | Display name |
roles | string[] | Roles array (merged from all providers) |
provider | string? | Which provider authenticated this session ("native", "keycloak", etc.) |
Import client-side auth helpers from @lumenjs/auth. The user state is hydrated from the server on page load. No extra fetch needed:
import { getUser, isAuthenticated, hasRole, login, logout } from '@lumenjs/auth';
| Function | Returns | Description |
|---|---|---|
getUser() | AuthUser | null | Current user object, or null if not logged in |
isAuthenticated() | boolean | Whether a user session exists |
hasRole(role) | boolean | Check if current user has a specific role |
login(returnTo?) | void | Redirect to /__nk_auth/login (with optional returnTo URL) |
logout() | void | Clear session and redirect to /__nk_auth/logout |
Example usage in a Lit component:
import { LitElement, html } from 'lit'; import { getUser, isAuthenticated, hasRole, login, logout } from '@lumenjs/auth'; export class NavBar extends LitElement { render() { const user = getUser(); return html` <nav> ${isAuthenticated() ? html` <span>Hi, ${user.name}</span> ${hasRole('admin') ? html`<a href="/admin">Admin</a>` : ''} <button @click=${() => logout()}>Sign out</button> ` : html`<button @click=${() => login()}>Sign in</button>`} </nav> `; } }
__nk_auth__ script tag injected during SSR. There is no flash of unauthenticated content. The user object is available immediately on first render.
Tokens & Verification -- Bearer tokens for mobile apps, email verification, and password reset flows.
API Reference -- All authentication endpoints and configuration options.