mosniauth

A quick-start for adding sign-in to an app and verifying the tokens it receives.

1. Frontend

One script tag, the branded <mosni-login-button>, and a state listener — that's the whole integration:

<script src="https://auth.nimos.ws/sdk.js"></script>

<!-- the branded button; the SDK defines it and wires its click to login() -->
<mosni-login-button></mosni-login-button>

<script>
  mosni.onChange((user) => {
    // null when signed out; the decoded JWT claims (sub, roles, mosni_owner, ...) when signed in
    console.log(user);
  });
</script>

The button is the recognizable, consistent mosni sign-in button. The SDK defines it for you (loading the standalone login-button.js from ui.mosni.dev when needed), so it works even in a custom-themed app that doesn't use the full chrome. Prefer your own trigger? Skip the button and call mosni.login() from any element.

Same-site tip: load the SDK from the auth host on your own app's registrable domain (e.g. an app on files.mosni.dev loads it from https://auth.mosni.dev/sdk.js) for flicker-free, same-site silent refresh via one background fetch — no iframe, no redirect. Genuinely external origins get the same script but fall back to a top-level redirect for refresh instead.

mosni.ready is a Promise<void> that resolves once the SDK's initial same-site session check has settled — either it found a live session or it concluded there isn't one, including by timing out. An app that must not paint a logged-out state before knowing the real answer can await mosni.ready before its first render. The first onChange callback is deferred until that same point (later callbacks still fire immediately, as always), so most apps that simply render from onChange need to change nothing — they just stop flashing a logged-out UI on a fresh tab.

1b. Frontend types (@mosni/auth-browser)

Optional, and TypeScript-only: a package that types the mosni object above so you stop hand-maintaining a declare global block that drifts. Pin the tarball — no registry, no .npmrc, no token:

// package.json
{
  "dependencies": {
    "@mosni/auth-browser": "https://auth.mosni.dev/packages/mosni-auth-browser-0.1.0.tgz"
  }
}

It ships types plus one helper, never the SDK itself — the SDK stays the <script> tag from §1, because it resolves which auth host to talk to from its own script URL. mosniReady() replaces the poll-for-window.mosni loop, and resolves only once the initial session check has settled (or with null if the script never loaded):

import { mosniReady } from "@mosni/auth-browser";

const sdk = await mosniReady();          // null if /sdk.js never loaded
sdk?.onChange((user) => setUser(user));  // already past the initial session check

2. Backend verify (TypeScript)

Pin the tarball URL in your dependencies. auth serves its own packages, so there is no registry, no .npmrc and no token — your deploy path never needs a build-time credential. Upgrading means editing the URL:

// package.json
{
  "dependencies": {
    "@mosni/auth": "https://auth.mosni.dev/packages/mosni-auth-0.1.0.tgz"
  }
}

Or skip the dependency entirely with the copy-paste snippet in the packages/verify-ts README. Usage:

import { verify, can } from "@mosni/auth";

const claims = await verify(token, "https://your-app.mosni.dev"); // audience = your app's own origin
if (can(claims, "files:write")) {
  // ...
}

3. Backend verify (Python / Django)

Install straight from a released git tag (no PyPI package):

pip install "mosni-auth @ git+https://github.com/mosni/auth.git@verify-py-v0.1.0#subdirectory=packages/verify-py"

Or the copy-paste snippet in the packages/verify-py README. Usage:

from verify_mosni import verify, can

claims = verify(token, "https://your-app.mosni.dev")  # audience = your app's own origin
if can(claims, "files:write"):
    ...

4. App-integration API (server-to-server, stack-network only)

Consumer apps on the stack docker network can mint temporary links and manage their own roles directly, with no /admin action from the owner. This API is served on a second, internal-only listener (INTERNAL_PORT) that is never published to the host and never proxied by nginx — it is reachable only by other containers on stack, and only your app's own backend can reach it (the browser cannot). Presence on the network is the entire authentication for this API; there is no token or session on it.

Your app is identified by the raw docker network peer, verified against a namespace you declare in every request — so your compose service needs a network alias equal to your role namespace (the same first-DNS-label rule as your own roles: files.mosni.dev uses namespace files) before your first call, or you get 403 unknown_caller:

services:
  app:
    networks:
      stack:
        aliases: [files]  # == your app's role namespace

4a. Temporary share links

POST /internal/links mints a link in your own namespace. The url contains a bearer token and is returned once — auth stores only its hash, so if you lose it, mint a new link. Hand it to your user; never log it.

POST http://auth:3001/internal/links
Content-Type: application/json

{
  "namespace": "files",
  "roles": [
    "files:write"
  ],
  "ttl_seconds": 14400,
  "destination": "https://files.mosni.dev/share/abc",
  "label": "share from files UI",
  "allow_register": false
}

201 Created
{
  "url": "https://auth.mosni.dev/i/<token>",
  "id": "<link_id>",
  "expires_at": "2026-07-18T14:00:00.000Z"
}

Whoever opens that URL gets an anonymous session bound to the link and is redirected to your destination, where your SDK obtains a token carrying the link's roles, aud = your origin and mosni_owner: false. The token never outlives the link. roles must all be grantable and in your namespace; label is optional and shown to the owner in /admin.

Early revoke is not on this API — ask the owner to revoke it in /admin. A link also simply expires at expires_at.

4b. Letting a link recipient keep the roles (upgrade)

Mint the link with allow_register: true and the claim page offers the recipient a choice: continue anonymously (exactly as above), or register a Google account and keep the link's roles permanently as real grants on that account. Use it for "invite a collaborator"; leave it false (the default) for a plain shared link.

What your app should know about an upgrade:

4c. User directory (people-picker)

GET /internal/accounts returns every account, projected to exactly {sub, name, picture} — never email, kind, or roles:

GET http://auth:3001/internal/accounts

200 OK
[
  {
    "sub": "google:1234",
    "name": "Ada",
    "picture": "https://auth.mosni.dev/avatar/google:1234"
  },
  {
    "sub": "hannah",
    "name": null,
    "picture": "https://auth.mosni.dev/avatar/hannah"
  }
]

Do not re-serve this list to anonymous visitors — that turns your app into a public user-enumeration endpoint. Show it only to a user already authorized to share the resource in question. name may be null; fall back to a truncated sub. picture is always https://auth.mosni.dev/avatar/<sub> — a public, cacheable route you can drop straight into an <img>. It never 404s: unknown or picture-less accounts get a default avatar.

The directory has a documented hard cap (2000 accounts, oldest first) rather than real pagination — plenty of headroom for this stack today. There is no ?page= parameter yet; if your app ever needs more than the cap, that's a real pagination change to the response contract, not a bigger number.

4d. Defining and granting your own roles

Two calls, no owner action needed. Define a role once, then grant it to any account that has signed in at least once. Both are scoped to your namespace, and neither can ever grant mosni_owner:

POST http://auth:3001/internal/grantable-roles     -> 200 {"ok": true}
{
  "namespace": "files",
  "role": "files:write",
  "action": "add"
}

POST http://auth:3001/internal/roles               -> 200 {"ok": true}
{
  "namespace": "files",
  "sub": "google:1234",
  "role": "files:write",
  "action": "add"
}

action: "remove" reverses each: on /internal/roles it revokes that account's role; on /internal/grantable-roles it de-lists the role so it can no longer be granted. De-listing does not revoke anything — existing grants survive and keep appearing in tokens until you revoke them individually. You can only de-list roles your app defined; a role the owner added in /admin stays theirs.

Granted roles reach your app as the roles claim on the next token that user gets for your origin — check them with can(claims, "files:write") from §2/§3. Revocation is eventual: it takes effect on the next token mint, within the normal token lifetime.

4e. Sharing a resource: which key to store

Auth grants nothing per-resource — your app owns the ACL. A token's sub is stable for the life of the account and survives an upgrade (§4b) — key your per-object ACL rows on the whole sub and match it byte-for-byte; never parse it. Subjects look like google:<id>, eve:<characterID>, link:<link_id>, or the owner's own sub.

The sharing rule, as a direct consequence. On a link with allow_register: false, every claimant of that link shares one sub — any per-person state you record against it merges across everyone who opens the link. That's fine for a drop-box or a view link, and wrong for anything genuinely per-person. On a link with allow_register: true, the first recipient to upgrade takes permanent ownership of the account and everything your app stored under it; if they forwarded the link before upgrading, that's between them and whoever they shared it with, but the consequence still lands in your app's data.

Gate on at least one role, not only your own per-object rows. If your app checks nothing but its own ACL table, an owner revoke has nothing to remove and cannot lock anyone out. Check a basic grantable role (§4d) for access alongside any per-object sharing you layer on top — that assumption is what makes revoke meaningful.

4f. Errors

Every failure is a JSON body {"error": "<code>"}:

403 unknown_caller       your declared `namespace` does not resolve to your container's IP
                         (missing/wrong compose alias) - fix the alias, above
403 forbidden_namespace  the role is outside your namespace, or is `mosni_owner`
400 not_grantable        role is not on the grantable list - add it first via
                         POST /internal/grantable-roles
400 namespace_mismatch   a link role's namespace is not yours
400 ttl_too_long         ttl_seconds must be an integer in (0, APP_LINK_TTL_MAX] (default 86400)
400 bad_destination      destination must be an absolute https:// URL, no user:pass@, not loopback
400 unknown_account      that `sub` has never signed in - this API never creates accounts
400 bad_role             role must look like `<namespace>:<capability>`, <= 100 chars
400 bad_request          a field is missing or the wrong type; `action` must be add|remove

Notes