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-auth-pluginpackage 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 two capability namespaces:
auth.identityauth.policy
Those capabilities are resolved by the SDK in the same general style as storage capabilities.
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-auth-plugin when available (with a core compatibility fallback when the package is absent):
auth.identity: noop→ always resolves to anonymous (nullidentity)auth.policy: noop→ always allows the action
The current release also ships a starter RBAC provider pair through kl-auth-plugin:
auth.identity: rbac→ validates opaque tokens against a runtime-owned principal registryauth.policy: rbac→ enforces a fixed cumulative role matrix foruser,manager, andadmin
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.
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-auth-plugin
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": "kl-auth-plugin",
"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": "kl-auth-plugin"
}
}
}
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) to enforce RBAC permissions |
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 and CLI calls running inside the same shell automatically pick up KANBAN_LITE_TOKEN from the environment — no extra configuration needed.
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.
-
No secrets in
.kanban.json- Workspace config can select providers.
- It must not store bearer tokens or similar secret material.
-
Action-level authorization only
- Current authorization is based on named SDK actions.
- It does not do partial filtering of card lists or board lists.
-
No-plugin = no behavior change
- The default path remains anonymous + allow-all via
noop, with a compatibility fallback whenkl-auth-pluginis not installed yet.
- The default path remains anonymous + allow-all via
Capability model
Auth uses two capability namespaces defined in src/shared/config.ts:
AuthCapabilityNamespace = 'auth.identity' | 'auth.policy'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 two 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
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-auth-pluginpackage 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,- SDK auth status reporting,
- SDK pre-action authorization hooks,
- 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"
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 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 provider selection lives in .kanban.json under the plugins key, using the npm package name as the provider id. Both auth.identity and auth.policy capability namespaces sit alongside storage providers in the same plugins object.
Default (no auth)
Omit auth namespaces entirely — the SDK defaults both to noop (open-access).
Local provider config
Requires kl-auth-plugin installed. Passwords are bcrypt hashes (cost 12 recommended):
{
"plugins": {
"auth.identity": {
"provider": "kl-auth-plugin",
"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": "kl-auth-plugin" }
}
}
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": "kl-auth-plugin" },
"auth.policy": { "provider": "kl-auth-plugin" }
}
}
What this does in the current implementation:
- switches both auth capability ids to the
kl-auth-pluginRBAC provider pair - 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
Shape of auth config (full example)
{
"plugins": {
"auth.identity": {
"provider": "kl-auth-plugin",
"options": {}
},
"auth.policy": {
"provider": "kl-auth-plugin",
"options": {}
}
}
}
Normalization behavior
normalizeAuthCapabilities() in src/shared/config.ts guarantees that both auth namespaces are always resolved.
Lookup order: plugins["auth.identity"] → { provider: "noop" }.
If both plugins auth keys and the auth key are omitted entirely, the normalized result is:
{
"auth.identity": { "provider": "noop" },
"auth.policy": { "provider": "noop" }
}
So there is always a complete runtime auth capability map, even when the workspace has no auth config.
What must never go in .kanban.json
Do not store:
- bearer tokens,
- refresh tokens,
- session secrets,
- API keys used as auth credentials,
- cookies,
- or any other secret credential material.
.kanban.json is for provider selection/configuration, not secret storage.
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 plugins["auth.identity"].provider or plugins["auth.policy"].provider is "kl-auth-plugin", the standalone server automatically loads the standalone.http capability exported by createStandaloneHttpPlugin from kl-auth-plugin.
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-auth-plugin 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-auth-plugin 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-auth-plugin'
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.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
process.env.KANBAN_TOKEN - wraps mutators in
sdk.runWithAuth(resolveCliAuthContext(), fn) - if 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 = noop
Explicitly declaring noop auth
{
"auth": {
"auth.identity": { "provider": "noop" },
"auth.policy": { "provider": "noop" }
}
}
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.policy
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
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.