npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mikser-io-auth

v0.14.0

Published

Authentication for mikser-io: an OAuth 2.1 authorization server (self-registering clients, authorization code + PKCE, refresh rotation) and HTTP Basic / JWT verifiers over Apache-format htpasswd and htgroup files in the working folder. Implements the ADR-

Downloads

1,118

Readme

mikser-io-auth

Authentication for mikser — HTTP Basic and JWT over Apache-format htpasswd / htgroup files in the working folder.

What it is

Mikser's engine ships an authentication seam (ADR-0012): a verifier contract, a constant-time static-token verifier, and the loopback policy that used to be hand-copied into every plugin that mounted a route. It ships no identity — no users, no groups, no login.

This package is the identity half. It plugs in wherever a static token plugs in, because it implements the same contract:

{ name, async verify(req), challenge?(req, res) }

Design choices, and why:

  • Users and groups are files, not a database. users.htpasswd and groups.htgroup sit in the working folder next to the content they govern. That is ADR-0002 applied to identity: reviewable in a pull request, diffable, deployable by copying a directory, editable with htpasswd(1) — a tool that predates every framework this project will outlive. The format is not ours to version or migrate.

  • The files are provisioned, never written at runtime. No signup, no password reset, no "create user" tool. A build tool has operators, not members. The moment mikser writes an htpasswd file it has a locking problem and a database it won't admit to. Edits are picked up without a restart — the files are re-read when their mtime moves.

  • bcrypt is the format worth trusting. $apr1$ and {SHA} are supported because htpasswd emits them and an operator's existing file should keep working; both are weak. DES-crypt and MD5-crypt are refused outright rather than half-supported — silently rejecting is safer than a subtly wrong implementation that accepts something. Use htpasswd -B.

  • The signing key is a file, not a boot-time secret. Regenerating a key on boot silently invalidates every live token whenever the process cycles, which presents as an intermittent auth bug and is miserable to diagnose. auth.key is written 0600 on first run and reused after.

  • Scope is computed from the files, never from the request. A client cannot ask for capabilities it doesn't hold. Downstream enforcement is scope-only with no per-request re-read, which is safe because of this invariant — it is the load-bearing piece, not optional hardening.

  • Basic for people, JWT for clients. HTTP Basic is browser-native and needs no flow, which makes it right for api / forms / decap. MCP clients expect Bearer and a discovery document, so they get JWT.

Use

The whole thing, minimally:

import { auth } from 'mikser-io-auth'
import { api }  from 'mikser-io'
import { mcp }  from 'mikser-io-mcp'

const identity = auth()

export default async () => ({
    plugins: [
        identity,
        api({ endpoints: { admin:  { auth: identity } } }),
        mcp({ endpoints: { remote: { auth: identity } } }),
    ],
})

That is a working setup. htpasswd -B -c users.htpasswd alice and you can sign in; an agent can self-register and connect. Everything below is optional and narrows what you already have.

identity is both the plugin and the verifier, so auth: identity accepts whichever credential the caller has — Basic from a browser, Bearer from an agent. Use identity.basic() or identity.jwt() only to deliberately exclude one.

With no capability map configured, an authenticated user is unscoped: the endpoint's own operations list is the only limit, exactly as for a static token. Add capabilities when you want them to mean something:

const identity = auth({
    // paths are relative to the working folder; these are the defaults
    users:  'users.htpasswd',
    groups: 'groups.htgroup',
    key:    'auth.key',

    // groups → capabilities. The files stay pure identity; this decides
    // what a group is allowed to do. Once this exists, a user whose groups
    // grant nothing can do nothing.
    capabilities: {
        editors: ['api:update', 'mcp:use'],
        admins:  ['api:update', 'api:delete', 'mcp:use'],
    },

    // groups → rows, ANDed with the endpoint's own query
    scopes: {
        editors: { 'meta.href': { $regex: '^/web' } },
    },

    issuer: 'https://cms.example.com',
})

The sign-in page names the deployment from mikser's own external URL (runtime.options.url), falling back to the host you connected to. It is not a config option — the host in the address bar is the most honest name there is, because it is the thing in front of you and so cannot name a different deployment.

auth() returns a value that is both the lifecycle plugin and the factory for the verifiers — because config is evaluated before the runtime exists, so a verifier has to be nameable at config time while its store can only be opened once the working folder is known. Verifiers resolve their store lazily; plugin order doesn't matter, and forgetting to add identity to plugins: fails with a message saying so.

The files

<workingFolder>/
    users.htpasswd      alice:$2y$10$…          htpasswd -B -c users.htpasswd alice
    groups.htgroup      editors: alice bob
    auth.key            {"kid":…,"privateJwk":…}   generated on first run, 0600

auth.key is ONE key for the whole deployment, not one per user — every token for every subject is signed with it. It is written 0600 on first run and never rewritten.

Back it up. Nothing can reproduce it. If it goes missing, the next start generates a new one and everyone who was signed in has to authorise again — and mikser says so rather than leaving you to infer it from a wave of 401s: the kid is recorded in the durable store, and one that no longer matches raises the auth-signing-key-changed fault, which appears in mikser_ping. Restoring the file from backup is what brings those sessions back; leaving it means every client re-registers.

Whether to commit it is your call, and mikser does not touch your .gitignore. In a private repo, committing it is a legitimate backup for the one file nothing else can reproduce, and it is written once so it adds no churn. Against that, git history does not forget: if the repo's audience ever widens, rotating the key does not remove the old one from history. Weigh those for your own setup — the engine is in no position to.

(The engine does gitignore its own mikser.data.sqlite, for a different reason that holds regardless: that file is rewritten on every write, so committing it means a binary diff and a conflict every time.)

Keep it a file. A single instance is safer with the key on disk, where it never leaves the box. Only a multi-instance deployment behind a load balancer needs it shared — instances signing with different keys reject each other's tokens, which from a client looks exactly like random expiry.

Endpoints

Mounted at base (default /auth) when mikser runs with --server:

| | | | --- | --- | | GET /auth/authorize | the sign-in page | | POST /auth/authorize | verify against users.htpasswd, redirect back with a code | | POST /auth/token | authorization_code, refresh_token, or password | | POST /auth/register | RFC 7591 self-registration — the only way a client exists | | GET /auth/jwks.json | the public half of the signing key | | GET /auth/.well-known/oauth-authorization-server | RFC 8414 metadata | | GET /auth/logo.svg | the mark on the sign-in page |

For a script or CLI, skip the browser entirely:

curl -u alice:alice-pw -X POST https://cms.example.com/auth/token

Clients — there is no client config

Agents register themselves (RFC 7591). There is no list to maintain, no per-agent redirect to write down, and no way to declare one.

A clients: map sounds harmless and isn't: it makes the set of agents that can connect equal to the set somebody thought to write down, so every new agent becomes a config change and a deploy. And an agent whose UI takes a URL and nothing else has no field to type a client_id into — it registers or it cannot connect at all.

Tune the bounds if you need to:

auth({ dcr: { maxPerIp: 5, windowMs: 3600_000, maxClients: 1000 } })

Public clients either way: PKCE (S256) required, no client_secret, because a browser or a native agent cannot keep one.

POST /auth/register is unauthenticated by necessity — you need a client_id before you can authenticate, so a token requirement would make the endpoint useless to the only callers that need it. That is safe because a registered client can do nothing on its own: it holds no tokens and represents no person, and cannot act until someone signs in on the page, where what they can do comes from their groups rather than from anything the client asked for. What is at risk is table volume, not access — hence maxPerIp (in-process, counts every request including rejected ones) and maxClients (a row count, the bound that survives a restart).

Registration names are attacker-controlled, so they are length-capped and HTML-escaped where they render.

Every registration mints a new client_id — RFC 7591 has no get-or-create — so a reinstall or a second machine leaves another row behind. Registrations nobody ever signed in with are pruned after pruneClientsAfterDays (30).

Redirect URIs

Matched exactly, with one exception: RFC 8252 §7.3 requires the port of a loopback URI to be ignored, because a native client binds an ephemeral port it cannot know in advance. Every MCP client that opens a browser depends on this. Scheme, host, path, query and fragment still match exactly.

A self-registering client is held to a stricter rule than an operator writing config, because nobody reviewed it: https anywhere, http only on loopback, and never a fragment.

The sign-in page

Deliberately identical to WhiteBox's — same layout, type scale, tokens and pending state — because mikser and WhiteBox are the same company's products and someone who administers both should not have to wonder which one they are looking at. The mark is the only difference, and logo: overrides it.

The page names two things, and the second is the one that matters: which deployment (appName), and who is asking for access (the client). Without the second, signing in to your own site and handing an agent your permissions look identical.

Grants

Authorization codes (60s, single-use) and refresh tokens (30d, rotated on every use) live in the engine's sqlite (ADR-0009), under mikser_auth_*. Identity stays in files; this is session bookkeeping.

The engine wipes that database when its schema stamp or config checksum changes — an upgrade, or any deploy that edits mikser.config.js. These tables are registered durable: true, so the wipe drops table by table and keeps them: a registered client and its refresh token exist only because a human completed a sign-in once, and they are not something the working folder can rebuild.

Minted tokens

auth() publishes a minting surface at runtime.options.auth, so another plugin can hand out a credential narrower than the caller's own without importing this package:

runtime.options.auth.mint({
    subject:      principal.subject,
    capabilities: principal.capabilities,   // what they already hold
    request:      ['webdav:media', 'webdav:media:write'],
    ttlSec:       300,
    purpose:      'webdav:media (write)',
})

The scopes are the intersection of request and capabilities. This can only ever narrow: there is no argument that widens anyone's reach, and asking for a scope the caller does not hold is refused with the missing one named rather than trimmed silently. A caller whose capabilities are null — a static token, not capability-scoped — is refused outright, because it cannot delegate what it cannot enumerate.

A minted token carries a jti and is recorded in mikser_auth_minted, which makes it revokable before it expires — the one thing an ordinary JWT is not, and the thing that matters for a credential handed to a machine that logs its own output. revokeMinted(jti) kills one; listMinted(subject) is the audit view. Every mint is logged with subject, purpose, scopes and ttl.

Verification fails closed: a token with a jti whose row is missing, or whose checker was never wired, is rejected. Losing the record must revoke, never un-revoke. Session tokens carry no jti, so they never touch this table and gain no new failure mode.

Minted tokens are not refreshable. Expiry is the revocation mechanism, so they are deliberately short and a caller mints again rather than renewing one that has been sitting in a transcript.

When an access token expires

Access tokens are short (ttl, default 1h) and refresh tokens are long, so a client is expected to notice the expiry and exchange quietly. It can only do that if the resource server says which failure it hit, and the vocabulary is RFC 6750 §3.1:

| Situation | Status | WWW-Authenticate | What a client should do | | --- | --- | --- | --- | | no credential presented | 401 | no error — the omission is the signal | start a sign-in | | access token expired | 401 | error="invalid_token", error_description="The access token expired" | exchange the refresh token, retry | | token malformed, wrong audience, wrong key | 401 | error="invalid_token" | sign in again; refreshing will not help | | token valid, subject lacks the capability | 403 | error="insufficient_scope", scope="<capability>" | do NOT refresh — a fresh token is refused identically |

All four used to be one byte-identical 401. A client cannot tell "your token went stale" from "you have never authenticated here" in that state, so it does the safe thing and starts a whole new authorization flow — a human, a browser, mid-task, with a perfectly good refresh token in hand.

The verifier reports which one it hit through rejectionFor(req), an optional method on the engine's ADR-0012 verifier contract. It can only narrow a denial that has already happened; there is nothing it can return that turns a rejection into an acceptance.

Not implemented

Deliberately out of scope: self-service registration, password reset, invites, and any cross-plugin permission catalog. A build server has operators, not members.

Auth on a mikser endpoint

Reachability is unchanged from the engine's rule, with one difference that matters:

| config | behaviour | | --- | --- | | nothing | loopback only, unless allowRemote | | token: '…' | valid token from anywhere; loopback still reaches it without the token | | auth: identity.basic() | the verifier gates every request — no loopback bypass | | auth: identity.jwt() | same, plus RFC 9728 discovery on MCP endpoints |

A static token keeps the internet out, not the developer running the build. A real verifier gets no bypass: if you wired it up, an unauthenticated loopback caller — another process on a shared box, an SSRF hop — is exactly what you were buying protection from.