Auth
Kanban Lite's auth layer is a two-part plugin contract: auth.identity resolves who is making the request, and auth.policy decides whether that identity is allowed to perform an action. Both default to no-op pass-through, so zero configuration is needed for local or single-user setups.
- Plug in any identity source — JWT, API key, session cookie, or custom header
- Policy plugins receive the resolved identity and the requested action for fine-grained RBAC
- First-party
kl-plugin-authpackage for RBAC and common identity strategies - Auth events integrate with the SDK event bus for audit logging via webhooks
Auth plugins and auth capabilities
This document is the detailed reference for Kanban Lite's auth system as it exists today in the codebase.
It explains:
- what the auth plugin capabilities are,
- how they are configured,
- how the SDK evaluates authorization,
- how each host surface passes tokens into the SDK,
- which actions are currently protected,
- what diagnostics are available,
- and what is intentionally not implemented yet.
This guide is deliberately more detailed than the short auth section in README.md and more implementation-focused than the ADR/plan documents under docs/plan/20260320-auth-authz-plugin-architecture/.
Executive summary
Kanban Lite models auth as three related capability namespaces:
auth.identityauth.policyauth.visibility
Those capabilities are resolved by the SDK in the same general style as storage capabilities, but they split responsibilities intentionally:
auth.identityresolves the caller and role list,auth.policyauthorizes protected actions,auth.visibilityoptionally filters card reads after identity resolution.
The key rule is:
If no auth plugin is configured, behavior must not change.
That rule is preserved by no-op providers resolved from kl-plugin-auth when available (with a core compatibility fallback when the package is absent) plus a disabled-by-default visibility slot:
auth.identity: noop→ always resolves to anonymous (nullidentity)auth.policy: noop→ always allows the actionauth.visibility: none→ disabled; card reads are unfiltered
The current release also ships a starter RBAC provider pair through kl-plugin-auth plus a separate first-party visibility package:
auth.identity: rbac→ validates opaque tokens against a runtime-owned principal registryauth.policy: rbac→ enforces a fixed cumulative role matrix foruser,manager, andadminauth.visibility: kl-plugin-auth-visibility→ filters cards with role-only rules after the SDK resolves identity and roles
kl-plugin-auth still owns identity resolution, tokens, sessions, and role lookup. kl-plugin-auth-visibility only filters the card set the SDK is about to return.
So a workspace with no auth configuration continues to behave exactly like an open-access workspace.
When a non-noop auth provider is active, the SDK performs pre-action authorization at the SDK method boundary before protected work executes. When auth.visibility is selected, the canonical cards read seam filters list/get flows so hidden cards behave as ordinary not-found or no-match results across host surfaces.
Getting started: fresh install with the local auth plugin
This section walks a new user through installing Kanban Lite in an empty folder with the local auth provider enabled so that the UI and REST API are protected by a username/password login and an API bearer token.
Prerequisites
- Node.js ≥ 18
- npm, pnpm, or yarn
Step 1 — create a project folder and initialise it
mkdir my-kanban && cd my-kanban
npm init -y
Step 2 — install kanban-lite and the auth plugin
npm install kanban-lite kl-plugin-auth
auth.visibility is optional and disabled by default. Install kl-plugin-auth-visibility only if you also want card filtering layered on top of identity/policy enforcement.
The standalone server binary is installed at node_modules/.bin/kanban-lite (or at the kanban-lite bin if you use npm scripts).
Step 3 — generate bcrypt password hashes
The local provider stores passwords as bcrypt hashes, never plain text. Generate one hash per user:
node -e "require('bcryptjs').hash('admin123', 12).then(h => console.log('admin :', h))"
node -e "require('bcryptjs').hash('manager123', 12).then(h => console.log('manager :', h))"
node -e "require('bcryptjs').hash('user123', 12).then(h => console.log('user :', h))"
Copy the three output strings — you will paste them into .kanban.json in the next step.
Step 4 — create .kanban.json with local auth
Create a .kanban.json file in your project root (substitute the hashes from Step 3):
{
"version": 2,
"port": 2954,
"plugins": {
"auth.identity": {
"provider": "local",
"options": {
"users": [
{ "username": "admin", "password": "<bcrypt-hash-for-admin>", "role": "admin" },
{ "username": "manager", "password": "<bcrypt-hash-for-manager>", "role": "manager" },
{ "username": "user", "password": "<bcrypt-hash-for-user>", "role": "user" }
]
}
},
"auth.policy": { "provider": "local" }
}
}
What each field does:
| Field | Purpose |
|---|---|
auth.identity.provider = "local" |
Resolves identity from a browser session cookie (set after /auth/login) or from the Authorization: Bearer <token> header |
auth.identity.options.users |
List of allowed username/bcrypt-password pairs for browser login. Each entry may include an optional role (user, manager, or admin) for downstream auth.policy and auth.visibility evaluation |
auth.policy.provider = "local" |
Allows any authenticated caller; denies anonymous requests |
Step 5 — set the API bearer token in .env
The local provider also accepts a shared API token for programmatic access (MCP, CLI calls, REST scripts). Generate one and put it in .env:
node -e "const c=require('crypto');console.log('KANBAN_LITE_TOKEN=kl-'+c.randomBytes(24).toString('hex'))"
Append the printed line to .env:
KANBAN_LITE_TOKEN=kl-<your-generated-token>
The
localprovider readsKANBAN_LITE_TOKEN(or the fallbackKANBAN_TOKEN) from the environment at startup. If neither is set, it auto-generates a token and writes it to.envthe first time the server starts.
Step 6 — start the server
npx kanban-lite
# or, if you added it to package.json scripts:
npm start
On first start you should see something like:
Kanban Lite listening on http://localhost:2954
Auth: local (identity) + local (policy)
Step 7 — log in via the browser
Open http://localhost:2954 in a browser. You will be redirected to /auth/login. Enter one of the usernames and passwords from Step 4. After a successful login you are redirected back and a session cookie is set — subsequent browser requests are authenticated automatically.
Log out at any time by visiting http://localhost:2954/auth/logout.
Step 8 — authenticate programmatic / REST calls
Pass the token from .env as a bearer token:
curl -H "Authorization: Bearer kl-<your-token>" \
http://localhost:2954/api/boards
MCP calls running inside the same shell automatically pick up KANBAN_LITE_TOKEN from the environment. CLI calls do the same, or you can pass --token <value> for a one-off invocation.
What is still open-access
The local policy provider allows any authenticated identity and denies anonymous callers. It does not enforce role-based restrictions between admin, manager, and user. If you need role-based access control, configure the rbac provider pair instead and supply a principal registry at runtime (see the Starter RBAC provider section below).
Design goals
The auth design is built around a few principles:
-
SDK first
- The SDK is the authoritative enforcement seam.
- REST, CLI, MCP, and the extension host must not implement their own allow/deny rules.
-
Host-owned token acquisition
- Each host decides where a token comes from.
- The SDK consumes a normalized
AuthContext.
-
Schema-defined provider options
- Workspace config persists selected provider ids and documented provider options.
- Shared Plugin Options read/list/error flows redact secret fields and reopen them as masked write-only placeholders instead of redisplaying raw values.
-
Separate action authorization from card visibility
auth.policyauthorization is based on named SDK actions.- Optional partial filtering of card reads lives in
auth.visibility, not insideauth.policy.
-
No-plugin = no behavior change
- The default path remains anonymous + allow-all via
noop, with a compatibility fallback whenkl-plugin-authis not installed yet.
- The default path remains anonymous + allow-all via
Capability model
Auth uses three capability namespaces defined in src/shared/config.ts:
AuthCapabilityNamespace = 'auth.identity' | 'auth.policy' | 'auth.visibility'AuthCapabilitySelections = Partial<Record<AuthCapabilityNamespace, ProviderRef>>ResolvedAuthCapabilities = Record<AuthCapabilityNamespace, ProviderRef>
The same ProviderRef shape used by storage capabilities is reused here:
interface ProviderRef {
provider: string
options?: Record<string, unknown>
}
That means auth provider selection looks structurally like storage provider selection.
The auth capabilities
auth.identity
Responsibility:
- take a host-supplied
AuthContext, - inspect token-related input,
- and resolve a normalized caller identity.
The runtime identity shape is defined in src/sdk/plugins/index.ts:
export interface AuthIdentity {
subject: string
roles?: string[]
}
Current shipped provider ids:
nooprbaclocal
Current noop behavior:
- always returns
null - treats the caller as anonymous
- preserves open-access behavior when no real identity plugin is active
Current rbac behavior:
- treats tokens as opaque strings
- strips a
Bearerprefix defensively before lookup - resolves identity from a runtime-owned principal registry (
token -> { subject, roles }) - returns
nullfor absent or unregistered tokens - never infers roles from token text
Important implementation detail:
- the exported singleton
RBAC_IDENTITY_PLUGINis constructed with an empty principal registry - that means selecting
auth.identity = rbacresolves a real provider id, but no token will authenticate until the host/runtime supplies principal material viacreateRbacIdentityPlugin(principals)and injects that plugin through custom capability wiring
Current local behavior:
Identity is resolved in the following priority order:
- Pre-installed identity — if
context.identityis already set (e.g. from a valid session cookie installed by thestandalone.httpmiddleware), that identity is returned directly. - API bearer token — if
Authorization: Bearer <token>matches theKANBAN_LITE_TOKEN/KANBAN_TOKENenvironment variable, the identity{ subject: context.actorHint ?? 'api-token' }is returned. - Actor hint — if
context.actorHintis present (e.g. set by the CLI or MCP surface), that value becomes the subject. null— anonymous caller; the local policy will deny the request.
Important implementation details:
KANBAN_LITE_TOKENtakes precedence over the fallbackKANBAN_TOKEN- token comparison uses
crypto.timingSafeEqualto prevent timing attacks - a
Bearerprefix is stripped before comparison - the exported singleton
LOCAL_IDENTITY_PLUGINis stateless and suitable for standalone use - when the
localprovider is active inside the standalone server,createStandaloneHttpPluginauto-generates akl-…token and persists it to<workspaceRoot>/.envif neither env var is set
auth.policy
Responsibility:
- receive the resolved identity,
- receive the canonical action name,
- receive the normalized
AuthContext, - return an allow/deny decision.
The decision shape is defined in src/sdk/types.ts:
export interface AuthDecision {
allowed: boolean
reason?: AuthErrorCategory
actor?: string
metadata?: Record<string, unknown>
}
Current shipped provider ids:
nooprbaclocal
Current noop behavior:
- always returns
{ allowed: true } - allows every protected action
- preserves open-access behavior when no real policy plugin is active
Current rbac behavior:
- denies
nullidentity withreason = 'auth.identity.missing' - checks the caller's roles against the fixed
RBAC_ROLE_MATRIX - returns
{ allowed: true, actor: identity.subject }on success - returns
{ allowed: false, reason: 'auth.policy.denied', actor: identity.subject }when the action is outside the caller's role set - implements three cumulative roles:
user,manager, andadmin
Current local behavior:
- denies
nullidentity withreason = 'auth.identity.missing' - allows all actions for any non-null identity (
{ allowed: true, actor: identity.subject }) - does not enforce role distinctions — every authenticated caller has equal access
- intended for single-operator setups or when role separation is not required
auth.visibility
Responsibility:
- receive the SDK-resolved identity,
- receive the normalized role list,
- receive the active
AuthContext, - filter a provided card set without re-resolving identity or sessions.
The normalized visibility input shape is defined in src/sdk/plugins/index.ts:
export interface AuthVisibilityFilterInput {
identity: AuthIdentity | null
roles: readonly string[]
auth: AuthContext
}
Current shipped provider ids:
nonekl-plugin-auth-visibility
Current none behavior:
- disables visibility filtering entirely
- preserves existing open card-read behavior when the capability is omitted
Current kl-plugin-auth-visibility behavior:
- selects rules by roles only
- unions cards granted by multiple matching rules
- applies AND semantics across different fields in one rule
- applies OR semantics within one field
- supports
@meinside assignee matching - does not grant implicit admin/manager bypass
- returns no visible cards when the caller matches no rules
Important implementation details:
- the canonical runtime seam lives in
src/sdk/modules/cards.ts - the SDK resolves identity once, normalizes missing or empty roles to
[], and passes that input into the visibility provider - downstream direct card reads and card-targeted host flows inherit hidden-as-not-found behavior because they resolve through visibility-scoped
listCards()/getCard()
Current implementation status
This part is important because the architecture is slightly broader than the currently shipped resolver behavior.
What exists today
Today the codebase includes:
- auth capability types in config,
- auth plugin interfaces,
kl-plugin-authpackage with three fully-shipped provider ids:noop,rbac, andlocal,noopidentity/policy providers that preserve open-access behavior,rbacidentity/policy providers with runtime-backed token registry,localidentity/policy providers for username/password + API token auth,createStandaloneHttpPlugin— registers/auth/login,/auth/logout, and identity middleware for thelocalprovider,- the exported
createRbacIdentityPlugin(principals)helper for runtime-backed token validation, - the fixed
RBAC_USER_ACTIONS,RBAC_MANAGER_ACTIONS,RBAC_ADMIN_ACTIONS, andRBAC_ROLE_MATRIXexports, RbacRoletype ('user' | 'manager' | 'admin'),- listener runtime helpers:
createAuthListenerPlugin,createLocalAuthListenerPlugin,createNoopAuthListenerPlugin,createRbacAuthListenerPlugin, - the
ProviderBackedAuthListenerPluginclass for custom listener wiring, authListenerPluginFactoriesconvenience map,authIdentityPluginsandauthPolicyPluginsprovider registries,- auth visibility provider loading and capability wiring,
- the
kl-plugin-auth-visibilitypackage with shared rule authoring metadata, - SDK auth status reporting,
- SDK pre-action authorization hooks,
- SDK card-read visibility filtering in
src/sdk/modules/cards.ts, - normalized host
AuthContextwiring for standalone, CLI, MCP, and extension host surfaces, - auth diagnostics/status endpoints and commands,
- tests for the auth seam and host wiring.
What is still intentionally limited
The shipped auth provider ids are:
auth.identity.provider = "noop" | "rbac" | "local"auth.policy.provider = "noop" | "rbac" | "local"auth.visibility.provider = "none" | "kl-plugin-auth-visibility"
If another provider id is selected, the resolver treats it as an external package name and throws an actionable install/shape error when the package cannot be loaded.
So the system now has:
- the capability contract,
- the SDK enforcement seam,
- the built-in
noopopen-access default, - the built-in starter
rbacpolicy implementation, - the opt-in
auth.visibilityseam with a disabled default, - the standalone
kl-plugin-auth-visibilitypackage for role-based card filtering, - the turnkey
localusername/password + API token provider with a browser login UI, - and the host integration path for token acquisition.
One remaining limitation:
- The shipped
RBAC_IDENTITY_PLUGINsingleton uses an empty registry; real RBAC token validation requires host/runtime wiring viacreateRbacIdentityPlugin(principals). Thelocalprovider does not have this limitation — it works out of the box with.kanban.jsonuser config.
Configuration
Auth now participates in the same shared Plugin Options workflow as other plugin-backed capabilities. The settings UI discovers auth providers from installed packages, shows the supplying package separately from the provider id, and persists the selected provider id plus its options under .kanban.json -> plugins.
For auth.identity / auth.policy, the package is typically kl-plugin-auth, while the selected provider ids are usually local, rbac, or noop. auth.visibility is supplied by kl-plugin-auth-visibility and defaults to disabled (provider: "none") until selected. The legacy provider: "kl-plugin-auth" alias remains available for compatibility, but the shared workflow selects the explicit provider rows and does not use a separate auth-enabled boolean.
Shared Plugin Options workflow
- Install or discover
kl-plugin-auth, and addkl-plugin-auth-visibilityonly if you want card filtering. - Open Settings → Plugin Options.
- Select a provider row for
auth.identityandauth.policy(for examplelocalorrbac), and optionally forauth.visibility. - Save provider options from the schema-driven form generated by the provider's
optionsSchema()output. - The selected provider id is persisted at
plugins["auth.identity"].provider/plugins["auth.policy"].provider/plugins["auth.visibility"].provider; selecting a provider is the enablement state. Leavingauth.visibilityomitted (or set toprovider: "none") keeps filtering disabled.
Secret-bearing auth fields participate in the same masked edit flow as other providers:
auth.identity.options.apiTokenauth.identity.options.users[*].password
Those values reopen as •••••• in the shared workflow. Leaving the masked placeholder unchanged keeps the stored secret/hash, and typing a new value replaces it. Read/list/error payloads stay redacted and do not redisplay the raw stored value.
Plugin settings auth split
Shared plugin-settings surfaces now use two auth actions:
plugin-settings.readcontrols plugin-settings inventory/detail reads before any plugin-settings payload is materialized. This covers the Settings panel hosts, the standalone websocket settings bridge (openSettings,loadPluginSettings,readPluginSettings), RESTGET /api/plugin-settingsandGET /api/plugin-settings/:capability/:providerId, CLIplugin-settings list|show, and MCPlist_plugin_settings.plugin-settings.updatecontrols provider selection, option updates, and guarded installs. This covers the Settings panel hosts, the standalone websocket bridge mutation messages, RESTselect/options/install, CLIplugin-settings select|update-options|install, and the matching MCP mutation tools.- Allowed reads still reuse the shared redaction contract (
••••••masked write-only placeholders plus redacted error payloads). - Redaction does not replace authorization; callers still need the matching auth action before any inventory or provider read model is returned.
In the shipped RBAC defaults, only admin gets these actions automatically. A default user therefore cannot list/view plugin settings, while an admin can list/show redacted settings and perform plugin-settings mutations unless you override the permission matrix.
Default (no auth)
Omit auth namespaces entirely — the SDK defaults auth.identity and auth.policy to noop (open-access) and auth.visibility to none (disabled).
Local provider config
Requires kl-plugin-auth installed. Passwords are bcrypt hashes (cost 12 recommended):
{
"plugins": {
"auth.identity": {
"provider": "local",
"options": {
"users": [
{ "username": "admin", "password": "$2b$12$...", "role": "admin" },
{ "username": "manager", "password": "$2b$12$...", "role": "manager" },
{ "username": "user", "password": "$2b$12$...", "role": "user" }
]
}
},
"auth.policy": { "provider": "local" }
}
}
Set KANBAN_LITE_TOKEN in .env for API bearer token access (auto-generated if absent):
KANBAN_LITE_TOKEN=kl-<48-hex-chars>
Starter RBAC config
{
"plugins": {
"auth.identity": { "provider": "rbac" },
"auth.policy": { "provider": "rbac" }
}
}
What this does in the current implementation:
- switches both auth capability ids to the
rbacprovider pair supplied bykl-plugin-auth - enables action-level authorization using the fixed SDK-owned role matrix
- requires the host/runtime to provide the principal registry used by
createRbacIdentityPlugin(principals)if you want any token to resolve successfully
What this does not do:
- it does not store token-to-role mappings in
.kanban.json - it does not create a login flow
- it does not make the empty-registry
RBAC_IDENTITY_PLUGINsingleton accept arbitrary token text
Visibility provider config
Requires kl-plugin-auth-visibility installed alongside whichever identity/policy provider resolves the caller.
{
"plugins": {
"auth.identity": { "provider": "local" },
"auth.policy": { "provider": "local" },
"auth.visibility": {
"provider": "kl-plugin-auth-visibility",
"options": {
"rules": [
{
"roles": ["design"],
"statuses": ["backlog"],
"labels": ["ux"]
}
]
}
}
}
}
If auth.visibility is omitted, normalizeAuthCapabilities() resolves it to { provider: "none" }. When enabled, callers only see cards granted by matching role rules, and hidden cards behave as standard not-found / no-match results across SDK, REST, CLI, MCP, and UI surfaces.
Shape of auth config (full example)
{
"plugins": {
"auth.identity": {
"provider": "local",
"options": {}
},
"auth.policy": {
"provider": "local",
"options": {}
},
"auth.visibility": {
"provider": "none"
}
}
}
Normalization behavior
normalizeAuthCapabilities() in src/shared/config.ts guarantees that all three auth namespaces are always resolved.
Lookup order:
plugins["auth.identity"]→{ provider: "noop" }plugins["auth.policy"]→{ provider: "noop" }plugins["auth.visibility"]→{ provider: "none" }
If both plugins auth keys and the auth key are omitted entirely, the normalized result is:
{
"auth.identity": { "provider": "noop" },
"auth.policy": { "provider": "noop" },
"auth.visibility": { "provider": "none" }
}
So there is always a complete runtime auth capability map, even when the workspace has no auth config.
What belongs in .kanban.json and what does not
For auth providers, .kanban.json persists the selected provider ids plus documented provider options from the shared schema-driven workflow. That can include:
auth.identity.options.usersentries (with bcrypt password hashes),auth.identity.options.apiTokenwhen you intentionally pin an explicit API token,- and
auth.policy.options.matrixoverrides.
The Plugin Options workflow never redisplays raw secret values for those fields. Instead, secret-bearing paths reopen masked as ••••••, and read/list/error payloads stay redacted.
Still do not store runtime-only or unrelated secrets such as:
- session cookies,
- cookie-signing or session secrets,
- refresh tokens,
- CSRF secrets,
- or credentials that are not part of the provider's documented schema.
Auth plugin contracts
The core auth plugin contracts live in src/sdk/plugins/index.ts.
Identity plugin contract
export interface AuthIdentityPlugin {
readonly manifest: AuthPluginManifest
resolveIdentity(context: AuthContext): Promise<AuthIdentity | null>
}
Policy plugin contract
export interface AuthPolicyPlugin {
readonly manifest: AuthPluginManifest
checkPolicy(
identity: AuthIdentity | null,
action: string,
context: AuthContext
): Promise<AuthDecision>
}
Auth plugin manifest
export interface AuthPluginManifest {
readonly id: string
readonly provides: readonly AuthCapabilityNamespace[]
}
That means an auth plugin identifies itself by:
- a provider id,
- and the auth namespace(s) it implements.
Starter RBAC helper contract
The built-in starter RBAC implementation also exports a small runtime helper contract:
export interface RbacPrincipalEntry {
subject: string
roles: string[]
}
export function createRbacIdentityPlugin(
principals: ReadonlyMap<string, RbacPrincipalEntry>,
): AuthIdentityPlugin
That helper is the runtime-backed identity path for the shipped rbac provider:
- keys are opaque tokens
- values contain the resolved caller
subjectandroles - unknown tokens resolve to
null - roles come from runtime-owned data, not token parsing
The built-in singleton RBAC_IDENTITY_PLUGIN simply calls this helper with new Map().
local provider — standalone.http capability
When either auth capability resolves to a provider supplied by kl-plugin-auth (for example local or the legacy kl-plugin-auth alias), the standalone server automatically loads the standalone.http capability exported by createStandaloneHttpPlugin from kl-plugin-auth.
What it registers
Middleware (runs before every request):
- reads
Authorization: Bearer <token>and compares it to the workspace API token usingcrypto.timingSafeEqual - reads the
kanban_lite_sessioncookie and looks up the session in an in-memory store - if neither succeeds:
- API requests (
/api/...) →401 { ok: false, error: "Authentication required" } - Page requests →
302redirect to/auth/login?returnTo=<current path>
- API requests (
Routes:
| Method | Path | Behavior |
|---|---|---|
GET |
/auth/login |
Serves the login HTML form. Redirects to returnTo if already authenticated. |
POST |
/auth/login |
Verifies username + bcrypt password. On success: sets session cookie, redirects to returnTo. On failure: re-renders form with error. Accepts both application/json and application/x-www-form-urlencoded. |
GET or POST |
/auth/logout |
Deletes the session, clears the cookie, redirects to /auth/login. |
Session details
| Property | Value |
|---|---|
| Cookie name | kanban_lite_session |
| Cookie flags | HttpOnly; SameSite=Lax; Path=/ |
| TTL | 7 days |
| Storage | In-memory Map (lost on server restart) |
| Session ID | 24 random bytes, hex-encoded |
API token auto-provisioning
On startup, createStandaloneHttpPlugin calls ensureWorkspaceApiToken(workspaceRoot):
- Checks
KANBAN_LITE_TOKENandKANBAN_TOKENenvironment variables. - If found, uses that value (and back-fills
process.env.KANBAN_LITE_TOKENif onlyKANBAN_TOKENwas set). - If neither is set, generates a
kl-<48-hex-chars>token, writesKANBAN_LITE_TOKEN=<token>to<workspaceRoot>/.env, and setsprocess.env.KANBAN_LITE_TOKEN.
Security notes
- Passwords are never stored in plain text — only bcrypt hashes in
.kanban.json. - Token comparison uses
crypto.timingSafeEqualto prevent timing attacks. returnTois validated to reject external redirects (must start with/and must not start with//).- The login form HTML-escapes all user-supplied values before rendering.
- Session IDs are cryptographically random; they are never derived from user input.
Listener runtime helpers
kl-plugin-auth exports a set of standalone listener helpers that plug into the SDK event bus directly, separate from the capability-provider path. These are useful when you want to enforce auth from application code rather than from .kanban.json config.
ProviderBackedAuthListenerPlugin
The underlying class that all factory functions produce:
class ProviderBackedAuthListenerPlugin implements SDKEventListenerPlugin {
readonly manifest: { readonly id: string; readonly provides: readonly string[] }
constructor(
authIdentity: AuthIdentityPlugin,
authPolicy: AuthPolicyPlugin,
options?: AuthListenerPluginOptions,
)
register(bus: EventBus): void
unregister(): void
}
Registers across all SDK before-events. For each event it:
- Merges the request-scoped
AuthContext(fromoptions.getAuthContext?.()) with event payload hints. - Calls
authIdentity.resolveIdentity(context). - Calls
authPolicy.checkPolicy(identity, action, context). - On denial: emits
auth.deniedand throwsAuthError. - On success: emits
auth.allowedand optionally returnsoptions.overrideInput(...)as an input override.
AuthListenerPluginOptions
interface AuthListenerPluginOptions {
id?: string
getAuthContext?: () => AuthContext | undefined
overrideInput?: (context: AuthListenerOverrideContext) =>
BeforeEventListenerResponse | Promise<BeforeEventListenerResponse>
}
| Field | Purpose |
|---|---|
id |
Custom plugin manifest id (defaults to auth-listener:<identity id>:<policy id>). |
getAuthContext |
Callback that returns the active request-scoped AuthContext. Use this to thread auth from your host surface into the listener. |
overrideInput |
Optional callback called after a successful auth decision. Return a plain object to deep-merge into the before-event input. |
AuthListenerOverrideContext
Passed to overrideInput:
interface AuthListenerOverrideContext {
readonly payload: BeforeEventPayload<Record<string, unknown>>
readonly identity: AuthIdentity | null
readonly decision: AuthDecision
}
Factory functions
// Wrap any identity+policy pair
createAuthListenerPlugin(
identity: AuthIdentityPlugin,
policy: AuthPolicyPlugin,
options?: AuthListenerPluginOptions,
): ProviderBackedAuthListenerPlugin
// local provider pair
createLocalAuthListenerPlugin(
options?: AuthListenerPluginOptions,
): ProviderBackedAuthListenerPlugin
// noop provider pair (open-access)
createNoopAuthListenerPlugin(
options?: AuthListenerPluginOptions,
): ProviderBackedAuthListenerPlugin
// rbac provider pair with optional principal registry
createRbacAuthListenerPlugin(
principals?: ReadonlyMap<string, RbacPrincipalEntry>,
options?: AuthListenerPluginOptions,
): ProviderBackedAuthListenerPlugin
authListenerPluginFactories
A convenience map keyed by provider id:
const authListenerPluginFactories: {
local: typeof createLocalAuthListenerPlugin
noop: typeof createNoopAuthListenerPlugin
rbac: typeof createRbacAuthListenerPlugin
}
auth.allowed and auth.denied events
Both events are emitted on the SDK event bus by the listener after every authorization decision:
// auth.allowed
{
type: 'auth.allowed',
data: { action: string, actor?: string },
timestamp: string,
actor?: string,
boardId?: string,
}
// auth.denied
{
type: 'auth.denied',
data: { action: string, actor?: string, reason: AuthErrorCategory },
timestamp: string,
actor?: string,
boardId?: string,
}
Plugin registries
kl-plugin-auth exports two flat registry objects for programmatic plugin lookup:
const authIdentityPlugins: Record<string, AuthIdentityPlugin> = {
local: LOCAL_IDENTITY_PLUGIN,
noop: NOOP_IDENTITY_PLUGIN,
rbac: RBAC_IDENTITY_PLUGIN, // empty principal registry
}
const authPolicyPlugins: Record<string, AuthPolicyPlugin> = {
local: LOCAL_POLICY_PLUGIN,
noop: NOOP_POLICY_PLUGIN,
rbac: RBAC_POLICY_PLUGIN,
}
These are used by the SDK's capability resolver to look up providers by the id string from .kanban.json. You can also use them directly in application code:
import { authIdentityPlugins, authPolicyPlugins } from 'kl-plugin-auth'
const identity = authIdentityPlugins['local']
const policy = authPolicyPlugins['local']
Starter RBAC provider
The current shipped RBAC contract is intentionally small and fixed.
Canonical roles
usermanageradmin
Roles are cumulative upward:
managerincludes everyuseractionadminincludes everymanageranduseraction
Identity-side behavior
- token validation is runtime-owned
- token values are opaque strings
- a defensive
Bearerprefix strip happens before the registry lookup - the resolved identity shape is still just:
{ subject: string, roles?: string[] }
Policy-side behavior
The built-in rbac policy provider consumes RBAC_ROLE_MATRIX.
nullidentity → deny withauth.identity.missing- action not covered by the caller's role set → deny with
auth.policy.denied - allowed action → returns the caller subject as
actor
Runtime boundary
The current built-in RBAC provider is Node-hosted only.
- hosts are responsible for token acquisition
- hosts are responsible for any runtime principal material
- the webview is not an auth plugin host
.kanban.jsonselects providers but does not contain token registries or secrets
The auth context passed into the SDK
Hosts normalize token-related information into AuthContext from src/sdk/types.ts.
Important fields include:
token?: stringtokenSource?: stringtransport?: stringactorHint?: stringboardId?: stringcardId?: stringfromBoardId?: stringtoBoardId?: stringcolumnId?: stringcommentId?: stringformId?: stringattachment?: stringlabelName?: stringwebhookId?: stringactionKey?: string
Why AuthContext exists
It gives the SDK one transport-neutral structure so the same authorization seam can work across:
- HTTP requests,
- WebSocket calls,
- CLI commands,
- MCP tools,
- and extension-host actions.
What the fields are for
tokenis the write-only credential input for identity resolution.tokenSourceis diagnostics-only metadata such asrequest-header,env, orsecret-storage.transportidentifies the surface such ashttp,cli,mcp, orextension.- resource hint fields let the policy plugin evaluate context-rich actions like:
- deleting a specific card,
- renaming a specific label,
- transferring between boards,
- submitting a specific form,
- or triggering a named action.
SDK runtime flow
The auth runtime is centered in src/sdk/KanbanSDK.ts.
Step 1: resolve configured auth capabilities
During SDK construction, auth capabilities are resolved via workspace config normalization.
Step 2: resolve capability bag
The resolved capability bag contains both:
authIdentityauthPolicy
If no auth config is present, both are no-op plugins.
Step 3: host surfaces install scoped auth
Node-hosted surfaces create an AuthContext from their inbound token source and call:
sdk.runWithAuth(authContext, fn)
This stores request auth in an async scope for the duration of fn instead of threading auth through mutation method signatures.
Step 4: protected methods enter the before-event seam
Protected SDK mutators call:
_runBeforeEvent(event, input, actor?, boardId?)
_runBeforeEvent() clones the original input immediately, dispatches the before-event payload, and owns the immutable deep-merge of any listener overrides.
Step 5: the built-in auth listener resolves identity and policy
The built-in auth listener reads the request-scoped auth carrier installed by runWithAuth(), enriches it with event hints such as actor and boardId, and then calls:
const identity = await authIdentity.resolveIdentity(context)
const decision = await authPolicy.checkPolicy(identity, action, context)
First-party auth listeners do not read payload.auth; BeforeEventPayload no longer carries auth state.
Step 6: denial vetoes the mutation with AuthError
If decision.allowed is false, the SDK throws AuthError.
That is what host surfaces use to map errors to:
- HTTP status codes,
- CLI output,
- MCP tool errors,
- and extension-host messages.
Sequence diagram
sequenceDiagram
participant Host as Host surface
participant SDK as KanbanSDK
participant Runner as _runBeforeEvent
participant Listener as Built-in auth listener
participant Identity as auth.identity
participant Policy as auth.policy
participant Op as Target operation
Host->>SDK: runWithAuth(authContext, fn)
Host->>SDK: call mutation(input)
SDK->>Runner: _runBeforeEvent(event, input, actor?, boardId?)
Runner->>Listener: dispatch BeforeEventPayload
Listener->>Identity: resolveIdentity(scoped context + hints)
Identity-->>SDK: identity | null
Listener->>Policy: checkPolicy(identity, action, context)
Policy-->>SDK: AuthDecision
alt allowed
Runner-->>SDK: merged input
SDK->>Op: execute mutation
Op-->>SDK: result
SDK-->>Host: success
else denied
Listener-->>Host: throw AuthError
end
Auth decisions and error categories
AuthErrorCategory in src/sdk/types.ts defines the canonical auth failure vocabulary.
Current categories are:
auth.identity.missingauth.identity.invalidauth.identity.expiredauth.policy.deniedauth.policy.unknownauth.provider.error
Why categories matter
These categories let hosts map failures without parsing fragile human-readable strings.
Examples:
- HTTP can turn identity failures into
401 - HTTP can turn deny failures into
403 - CLI can print a targeted auth message
- MCP can return machine-usable tool errors
AuthError
AuthError is the typed SDK error used when a protected action is denied.
It carries:
- a machine-readable category,
- a human-readable message,
- and optional actor information.
Current protected action surface
The current implementation protects the following SDK operations through the SDK-owned before-event auth listener that runs inside _runBeforeEvent(...).
Built-in RBAC role matrix
user
form.submitcomment.createcomment.updatecomment.deleteattachment.addattachment.removecard.action.triggerlog.add
manager
Includes all user actions plus:
card.createcard.updatecard.movecard.transfercard.deleteboard.action.triggerlog.clearboard.log.add
admin
Includes all manager and user actions plus:
board.createboard.updateboard.deletesettings.updateplugin-settings.readplugin-settings.updatewebhook.createwebhook.updatewebhook.deletelabel.setlabel.renamelabel.deletecolumn.createcolumn.updatecolumn.reordercolumn.setMinimizedcolumn.deletecolumn.cleanupboard.action.config.addboard.action.config.removeboard.log.clearboard.setDefaultstorage.migratecard.purgeDeleted
Same surface grouped by operation area
Board actions
board.createboard.updateboard.deleteboard.setDefaultboard.action.config.addboard.action.config.removeboard.action.triggerboard.log.addboard.log.clear
Card actions
card.createcard.updatecard.movecard.deletecard.transfercard.purgeDeletedcard.action.trigger
Form actions
form.submit
Attachment actions
attachment.addattachment.remove
Comment actions
comment.createcomment.updatecomment.delete
Log actions
log.addlog.clear
Column actions
column.createcolumn.updatecolumn.reordercolumn.setMinimizedcolumn.deletecolumn.cleanup
Label actions
label.setlabel.renamelabel.delete
Settings and storage actions
settings.updatestorage.migrate
Webhook actions
webhook.createwebhook.updatewebhook.delete
Important note on scope
Not every synchronous/local mutation path in the codebase is currently routed through the auth seam.
For example, the current action-protected surface is focused on the privileged async mutation seam already used by the Node-hosted adapters. The action matrix above is the authoritative list of what is currently protected.
This doc intentionally describes the implementation as it exists now, not as an aspirational superset.
How scoped auth and before-event hints work
Hosts do not pass auth through every SDK mutator. Instead, they establish request scope once with runWithAuth(...), and the SDK supplies operation hints when it dispatches each before-event.
Examples:
- a REST route extracts a bearer token, builds
{ token, tokenSource: 'request-header', transport: 'http' }, and wraps the mutator insdk.runWithAuth(...) deleteCard(cardId, boardId)dispatches a before-event whose payload includes the relevant card/board contexttransferCard(...)dispatches a before-event with the transfer-specific board and card hintsupdateComment(...)andsubmitForm(...)dispatch before-events that carry their operation-specific identifiers ininput
The auth listener combines that scoped request auth with the event payload's actor/board hints before resolving identity and policy.
Important boundary:
BeforeEventPayloadcontainsevent,input,actor,boardId, andtimestamp- it does not contain
auth - first-party auth listeners resolve auth from the scoped carrier, not from the payload
Host token acquisition and auth context sources
Each host surface is responsible for creating AuthContext.
Standalone HTTP API
Source file:
src/standalone/authUtils.ts
Behavior:
- reads
Authorization: Bearer <token> - strips the
Bearerprefix - wraps SDK mutators in
sdk.runWithAuth(extractAuthContext(req), fn) - produces:
{ token, tokenSource: 'request-header', transport: 'http' }
If no bearer token is present:
{ transport: 'http' }
Standalone WebSocket
The standalone WebSocket path also extracts auth context from the upgrade request and threads that same context into WebSocket-triggered mutations.
That means browser-triggered socket actions and REST mutations use the same token source model and the same runWithAuth(...)-scoped execution path on the standalone server.
CLI
Source file:
src/cli/index.ts
Behavior:
- reads
--token <value>first, then falls back toprocess.env.KANBAN_LITE_TOKENandprocess.env.KANBAN_TOKEN - wraps mutators in
sdk.runWithAuth(resolveCliAuthContext(), fn) - if
--tokenis present, produces:
{ token, tokenSource: 'flag', transport: 'cli' }
- otherwise, when an env token is present, produces:
{ token, tokenSource: 'env', transport: 'cli' }
- otherwise:
{ transport: 'cli' }
The CLI also has an auth status command:
kl auth status
MCP server
Source file:
src/mcp-server/index.ts
Behavior:
- reads
process.env.KANBAN_TOKEN - wraps mutators in
sdk.runWithAuth(resolveMcpAuthContext(), fn) - if present, produces:
{ token, tokenSource: 'env', transport: 'mcp' }
- otherwise:
{ transport: 'mcp' }
The MCP server exposes auth diagnostics via:
get_auth_status
VS Code extension host
Source files:
src/extension/auth.tssrc/extension/index.ts
Behavior:
- stores the token in VS Code
SecretStorage - secret key:
kanban-lite.authToken - wraps privileged mutations in
sdk.runWithAuth(await getAuthContext(), fn) - produces:
{ token, tokenSource: 'secret-storage', transport: 'extension' }
or, when no token is stored:
{ transport: 'extension' }
Extension commands:
Kanban Lite: Set Auth TokenKanban Lite: Clear Auth Token
Important boundary:
- the token stays in the Node extension host
- raw token material is not supposed to flow into the webview bundle
Diagnostics and status surfaces
There are two layers of auth diagnostics.
SDK-level status
sdk.getAuthStatus() returns:
identityProviderpolicyProvideridentityEnabledpolicyEnabled
This tells you which provider ids are active and whether they are non-noop.
Host-augmented status
Each host adds transport/token-source diagnostics on top of sdk.getAuthStatus().
Typical extra fields are:
configuredtokenPresenttokenSourcetransport
These status surfaces do not reveal token contents or the RBAC principal registry. They report safe metadata only: provider ids, whether auth is configured, whether a token is currently present, and the token-source / transport labels.
Standalone REST diagnostics
Endpoints:
GET /api/authGET /api/workspace
These expose safe auth metadata only.
CLI diagnostics
Command:
kl auth status
This prints:
- identity provider id
- policy provider id
- whether auth is configured
- whether a token is present
- token source label
- transport label
MCP diagnostics
Tool:
get_auth_status
Extension diagnostics
The extension computes auth status in the host and passes safe metadata to the UI state for display/awareness.
HTTP error mapping
The standalone auth utility maps AuthError categories to HTTP status codes.
Current mapping in src/standalone/authUtils.ts:
auth.identity.missing→401auth.identity.invalid→401auth.identity.expired→401auth.policy.denied→403auth.policy.unknown→403- everything else →
500
That mapping is intentionally category-based rather than string-based.
Security guarantees in the current design
The current implementation makes several explicit promises.
1. Tokens are host inputs, not workspace config
Tokens are acquired from:
- request headers,
- environment variables,
- or VS Code
SecretStorage.
They are not stored in .kanban.json.
2. Tokens are write-only
Raw tokens are intended to be consumed for identity resolution and not re-exposed.
They should not appear in:
- REST responses,
- CLI output,
- MCP output,
- logs,
- denial messages,
- or webview messages.
3. The webview is not an auth plugin host
Auth plugins are for Node-hosted surfaces.
The webview does not execute auth logic directly.
4. Policy is centralized
The SDK is the only authoritative allow/deny layer.
This avoids parity drift between:
- standalone server,
- CLI,
- MCP,
- and extension host behavior.
Reference examples
Minimal open-access workspace
No auth config at all:
{
"version": 2,
"defaultBoard": "default",
"boards": {
"default": {
"name": "Default",
"columns": [
{ "id": "backlog", "name": "Backlog", "color": "#6b7280" }
],
"nextCardId": 1,
"defaultStatus": "backlog",
"defaultPriority": "medium"
}
},
"kanbanDirectory": ".kanban",
"defaultPriority": "medium",
"defaultStatus": "backlog",
"nextCardId": 1
}
Normalized auth result:
auth.identity = noopauth.policy = noopauth.visibility = none
Explicitly declaring noop auth
{
"auth": {
"auth.identity": { "provider": "noop" },
"auth.policy": { "provider": "noop" },
"auth.visibility": { "provider": "none" }
}
}
This is functionally equivalent to omitting the auth block.
Selecting the built-in starter RBAC provider pair
{
"auth": {
"auth.identity": { "provider": "rbac" },
"auth.policy": { "provider": "rbac" }
}
}
This enables the built-in RBAC provider ids, but successful identity resolution still depends on runtime-owned principal data.
Example runtime principal registry
const principals = new Map([
['opaque-admin-abc', { subject: 'alice', roles: ['admin'] }],
['opaque-mgr-xyz', { subject: 'bob', roles: ['manager'] }],
['opaque-user-tok', { subject: 'carol', roles: ['user'] }],
])
const identityPlugin = createRbacIdentityPlugin(principals)
This helper-backed plugin validates tokens against runtime-owned data.
- unknown tokens resolve to
null - roles come from the registry entry
- token text is never parsed for role inference
Example host token usage
Standalone REST:
GET /api/auth HTTP/1.1
Authorization: Bearer <token>
CLI:
KANBAN_TOKEN=example-token kl auth status
MCP:
KANBAN_TOKEN=example-token kanban-mcp
Limitations and non-goals
The following are not currently implemented as a completed shared feature set.
No dynamic external auth provider loading yet
The current resolver supports two built-in auth provider ids:
nooprbac
But it does not yet dynamically load arbitrary external auth providers.
Also, the exported RBAC_IDENTITY_PLUGIN singleton uses an empty registry by default, so a host/runtime must provide principal material through createRbacIdentityPlugin(principals) if it wants real token validation.
No row/card filtering
Auth currently does not rewrite or filter list/query results.
If a protected action is denied, the system returns an error rather than silently filtering results.
No browser login UX contract
There is no standardized shared login flow such as OAuth popup or browser-mediated refresh flow.
No token refresh contract
Refresh behavior is not part of the shared auth contract.
No universal host-side token precedence framework
Current host token handling exists and is normalized into AuthContext, but the full future story for multi-source precedence and richer auth UX is still intentionally limited.
Relationship to storage capabilities
Auth capabilities are separate from storage capabilities.
Current storage capabilities:
card.storageattachment.storage
Current auth capabilities:
auth.identityauth.policyauth.visibility
They share the same broad configuration style and capability-resolution pattern, but they solve different problems:
- storage decides where data lives
- auth decides who may perform protected actions and, when
auth.visibilityis enabled, which cards are visible to that caller
Where to look in the codebase
If you want the implementation source of truth, start here:
Config and capability types
src/shared/config.ts
Auth plugin contracts and noop implementations
src/sdk/plugins/index.ts
Auth context, decisions, and errors
src/sdk/types.ts
SDK auth runtime and protected action hooks
src/sdk/KanbanSDK.ts
Standalone auth utilities
src/standalone/authUtils.ts
CLI auth adapter
src/cli/index.ts
MCP auth adapter
src/mcp-server/index.ts
VS Code extension auth adapter
src/extension/auth.tssrc/extension/index.ts
Tests
src/sdk/__tests__/plugin-registry.test.tssrc/sdk/__tests__/auth-enforcement.test.tssrc/standalone/__tests__/server.integration.test.ts
Planning and architecture docs
docs/plan/20260320-auth-authz-plugin-architecture/architecture-decision-record.mddocs/plan/20260320-auth-authz-plugin-architecture/architecture-requirements-stage-2.md
Final mental model
If you only remember five things, remember these:
- Auth is split into identity and policy capabilities.
- Both default to noop, so existing workspaces remain open unless auth is explicitly activated.
- The SDK is the only authoritative enforcement seam.
- Hosts supply tokens via
AuthContext, but secrets do not belong in.kanban.json. - The current release ships
noopplus a built-in starterrbacprovider pair, but live RBAC identity resolution still depends on runtime-owned principal data rather than anything stored in.kanban.json.