A quick-start for adding sign-in to an app and verifying the tokens it receives.
One script tag, the branded <mosni-login-button>, and a state listener — that's the
whole integration:
<script src="https://auth.mosni.dev/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.
@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 checkPin 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")) {
// ...
}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"):
...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 namespacePOST /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.
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:
sub never changes. The token's subject is the same
sub = "link:<id>" before and after the upgrade — the link is the account, from
its first click. Any per-object ACL row your app keyed on that sub keeps matching with no
migration and no work on your end (see §4e).sub never changes for an existing account — that guarantee is total, so a recipient who already
signed in with that Google identity sees "you already have an account" and grants nothing; they keep using
their existing account and sign in to your app the normal way instead.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.
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.
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.
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|removeaudience is required on every verify call and must equal your app's
own origin — a token is only valid for the exact origin in its aud.iss is always https://auth.mosni.dev, regardless of which auth vhost the
user actually logged in through.roles: []) until an owner grants
some; the owner account is superuser via mosni_owner: true.sub may be google:<id>, eve:<characterID>,
link:<link_id>, or the owner's own sub (§4e). Every EVE account has no
email — name may be null while email is null
for every single one of them, not just occasionally, so an app must not key anything on email.