- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| example | ||
| oidctest | ||
| sqlitestore | ||
| .gitignore | ||
| auth.go | ||
| breakglass.go | ||
| breakglass_test.go | ||
| CLAUDE.md | ||
| config.go | ||
| doc.go | ||
| errors.go | ||
| export_test.go | ||
| go.mod | ||
| go.sum | ||
| harness_test.go | ||
| login_test.go | ||
| mage.go | ||
| magefile.go | ||
| middleware.go | ||
| native.go | ||
| native_test.go | ||
| oidc.go | ||
| password.go | ||
| ratelimit.go | ||
| README.md | ||
| security_test.go | ||
| session.go | ||
| sqlc.yaml | ||
| store.go | ||
| token.go | ||
| tools.mod | ||
| tools.sum | ||
| unit_test.go | ||
| users.go | ||
lbsauth
Shared login for small personal/family web apps (and their native apps) that
use an OpenID Connect provider, typically Pocket ID,
as their only identity source. Go, stdlib net/http.ServeMux, no cgo.
What an app gets:
/auth/login,/auth/callback,/auth/logout: authorization code flow with PKCE (S256),stateandnonce, ID token verification (github.com/coreos/go-oidc/v3,golang.org/x/oauth2).- Provider groups mapped to app roles, recomputed at every login. Users in none of the app's groups are rejected.
- Local users keyed by the OIDC
sub, with a one-time link to a pre-created user by verified email. - Local app sessions (32 random bytes in an HttpOnly/Secure/SameSite=Lax cookie, only the SHA-256 stored, server-side expiry, pruning). Provider tokens are never used as sessions.
RequireUser/RequireRolemiddleware with cross-origin (CSRF) protection for cookie requests.- Break-glass admin login (one argon2id hash from env, rate-limited) for when the provider is down.
POST /api/v1/auth/oidc: native apps swap their ID token for an app bearer token.RevokeUserand an admin handler for "log out everywhere".- A
Storeinterface plus a ready SQLite implementation (sqlitestore, sqlc +modernc.org/sqlite, embedded migrations). oidctest: an in-process fake provider for your own tests.
go get git.lbsfilm.at/lbsadmin/lbsauth@latest
1. Set up the provider (Pocket ID)
Names below are placeholders; use your app's name for myapp and your own
hosts for id.example.com / myapp.example.com.
Instance settings. The email linking rule trusts email_verified. That is
only sound when users cannot set their own unverified addresses, so the
Pocket ID instance must run with:
| Setting | Value | Why |
|---|---|---|
ALLOW_USER_SIGNUPS |
disabled |
only the admin creates users |
ALLOW_OWN_ACCOUNT_EDIT |
false |
users cannot change their email |
EMAILS_VERIFIED |
true |
admin-entered emails count as verified |
If any of these change, revisit the linking rule (section 5).
Groups. Under Administration → User Groups create one group per role,
named <app>-<role>, e.g. myapp-admin, myapp-user. The groups claim
carries the group name, not the friendly name. Membership in any of the
app's groups grants access; there is no separate access group.
Web client. Administration → OIDC Clients → Add OIDC Client:
- Name:
myapp. Type: confidential (the app has a backend). - Callback URL:
https://myapp.example.com/auth/callback(=PUBLIC_BASE_URL+/auth/callback, exact match). - Create, then copy the client ID and the client secret (shown once) into
OIDC_CLIENT_ID/OIDC_CLIENT_SECRET. - Access tab → Selected user groups → tick the app's groups → Save. A new client lets nobody in until this is done. Pocket ID then refuses other users itself; lbsauth checks the groups again anyway.
Native client (only if the app has a native app). A second client:
- Name:
myapp-ios. Type: public (no secret; PKCE). - Callback URL: the app's custom-scheme redirect, e.g.
com.example.myapp:/oauthredirect. - Copy the client ID into
OIDC_NATIVE_CLIENT_ID. - Access tab: the same groups as the web client.
The discovery document is at https://id.example.com/.well-known/openid-configuration;
OIDC_ISSUER is https://id.example.com.
2. Configure the app
| Variable | Required | Meaning |
|---|---|---|
OIDC_ISSUER |
yes | provider base URL, e.g. https://id.example.com |
OIDC_CLIENT_ID |
yes | web client ID |
OIDC_CLIENT_SECRET |
yes for a confidential client | web client secret |
OIDC_NATIVE_CLIENT_ID |
no | public native client; empty disables /api/v1/auth/oidc |
PUBLIC_BASE_URL |
yes | external origin, e.g. https://myapp.example.com. http:// turns off the Secure flag (local dev only) |
<APP>_ADMIN_PASSWORD_HASH |
no | argon2id hash for break-glass login; empty disables it. Quote it in .env ('...'), it contains $ |
TRUSTED_PROXY_CIDRS |
no | comma-separated proxies whose X-Forwarded-For is believed (rate limiting) |
OIDC_GROUP_ROLES |
no | overrides the app's group→role mapping: myapp-admin=admin,myapp-user=user |
<APP> is the name passed to ConfigFromEnv, upper-cased with - → _.
Loading .env into the environment is the app's job.
3. Wire it up
cfg, err := lbsauth.ConfigFromEnv("myapp") // AppName "myapp", MYAPP_ADMIN_PASSWORD_HASH
if err != nil { return err }
if cfg.GroupRoles == nil { // unless OIDC_GROUP_ROLES is set
cfg.GroupRoles = lbsauth.GroupRoles("myapp", "admin", "user") // myapp-admin→admin, myapp-user→user
}
// The app's one CrossOriginProtection. lbsauth applies it to logout,
// break-glass login and every cookie request behind RequireUser/RequireRole.
cop := http.NewCrossOriginProtection()
cop.AddTrustedOrigin(cfg.PublicBaseURL)
cfg.CrossOriginProtection = cop
store, err := sqlitestore.New(ctx, appDB) // or sqlitestore.Open(ctx, "data/auth.db"), or your own Store
if err != nil { return err }
auth, err := lbsauth.New(cfg, store) // does not contact the provider; discovery is lazy
if err != nil { return err }
go auth.RunPruner(ctx, time.Hour) // deletes expired sessions and tokens
mux := http.NewServeMux()
auth.Mount(mux)
mux.Handle("GET /{$}", auth.LoadUser(home)) // optional user
mux.Handle("GET /items", auth.RequireUser(listItems)) // any app user
mux.Handle("POST /items", auth.RequireUser(createItem)) // CSRF-protected for cookies
mux.Handle("GET /admin/users", auth.RequireRole("admin", usersPage))
mux.Handle("POST /admin/users/{id}/logout-everywhere", auth.RevokeHandler())
In handlers:
u, ok := lbsauth.UserFrom(r.Context()) // u.ID, u.Name, u.Email, u.Roles, u.HasRole("admin")
lbsauth.MethodFrom(r.Context()) // MethodSession or MethodBearer
Routes added by Mount:
| Route | Notes |
|---|---|
GET /auth/login?next=/path |
starts the flow; next must be a local path |
GET /auth/callback |
redirect URI registered at the provider |
POST /auth/logout |
deletes the session; cross-origin protected; logs out of the app only |
GET/POST /auth/admin-login |
break-glass form and login; only when the hash is set |
POST /api/v1/auth/oidc |
native token exchange; only when OIDC_NATIVE_CLIENT_ID is set |
POST /api/v1/auth/logout |
native app revokes its own bearer token |
Individual handlers (LoginHandler, CallbackHandler, LogoutHandler,
BreakGlassFormHandler, BreakGlassHandler, NativeExchangeHandler,
NativeLogoutHandler, RevokeHandler) are exported for custom paths. The
mutating ones already apply Config.CrossOriginProtection; RevokeHandler
also requires AdminRole.
Unauthenticated browser navigations to a RequireUser route are redirected
to LoginURL (default /auth/login) with ?next=; API, fetch and Datastar
requests get 401. Point LoginURL at an app page to show a "Log in" button
(and a break-glass link) instead of redirecting straight to the provider.
Config.ErrorHandler renders error pages; lbsauth.IsPublicError(err) says
whether the error text may be shown.
Logout is a form POST: <form method="post" action="/auth/logout"><button>Log out</button></form>.
CLI. Add a hash-password subcommand:
case "hash-password":
return lbsauth.HashPasswordCommand(os.Stdin, os.Stdout, os.Stderr)
It prompts twice without echo on a terminal, or reads one line from stdin
(docker compose run --rm -T myapp hash-password <<< 'the password').
4. Sessions, tokens and CSRF
- Session cookie
__Host-<app>_session(plain<app>_sessionwith anhttp://base URL): 32 random bytes,HttpOnly,Secure,SameSite=Lax,Path=/. The__Host-prefix keeps other apps on sibling subdomains from planting or overwriting it. - Only
SHA-256(token)is stored. Expiry is absolute (default 7 days,SessionTTL), checked server-side;RunPrunerdeletes expired rows hourly. Absolute expiry also bounds how long a role removed in the provider survives without a "log out everywhere". - A login replaces the session the browser already had.
- Login state (
state,nonce, PKCE verifier,next) lives for 10 minutes in the HttpOnly cookie__Host-<app>_loginand is single-use. - Bearer tokens for native apps:
<app>_+ 32 random bytes, only the SHA-256 stored, default 30 days (BearerTokenTTL), accepted only inAuthorization: Bearer, never in query strings. - Every session and bearer token is stamped with the user's epoch (credential generation). A credential authenticates only while it is unexpired, its epoch equals the user's current one, and the user has at least one role. Revocation increments the epoch, so even a credential that a login in flight stores after the revocation is dead on arrival.
- CSRF: cookie-authenticated unsafe requests go through Go's
http.CrossOriginProtection(fromConfig.CrossOriginProtection, default trustingPUBLIC_BASE_URL). Bearer requests are exempt. Safe methods (GET/HEAD) must not change state.
5. Users, linking and pre-creation
At every login (browser or native), after the ID token is verified:
- Stale assertions are refused (401). Each user has a
NotBefore; an ID token whoseiatis earlier is rejected. It is raised by a revocation (to the revocation time), by a login rejected for missing groups (to just after that token) and by a login that changes the roles (to that token'siat). An older ID token therefore cannot restore removed access or roll roles back. - Roles = the app roles of the user's groups (
GroupRoles), sorted. No app group: rejected (403). If the user already exists, their roles are cleared and they are revoked (all sessions and bearer tokens, epoch,NotBefore). - A user with this
subexists: update name, roles, last login, and the email if it is verified and not taken by another user. - Else a user with this email exists and has no
subyet: link it (store thesub) only ifemail_verifiedis true. Unverified: rejected (403). The email belongs to a user with a differentsub: rejected (409). - Else create the user. Only verified emails are stored.
All writes are compare-and-set on the subject, epoch and NotBefore that
were read: of two identities racing to link one pre-created user exactly one
wins (the other gets 409 and no credentials), and a login racing a
revocation or unlink re-reads and is refused.
Pre-create a user before their first login so the app can grant permissions in advance (roles stay empty until they log in):
u, err := auth.PreCreateUser(ctx, "person@example.com", "Person") // ErrConflict if the email exists
The same rule links existing local accounts when an app is migrated: give
the legacy user row the person's email, keep sub empty, and the first
Pocket ID login links it.
If a person was deleted and re-created in the provider (new sub, same
email), their login fails with ErrSubjectConflict; an admin calls
auth.UnlinkUser(ctx, userID) and the next login links again. Unlinking
revokes the user, so only a login started after the unlink can link.
User IDs are opaque strings assigned by the store (sqlitestore: 26-char
random base32). Use User.ID as the foreign key in app tables.
6. Native apps
-
The app runs the authorization code flow with PKCE against the provider as the public native client (e.g.
flutter_appauth), scopesopenid profile email groups, ideally with its ownnonce. -
It posts the ID token right away:
POST /api/v1/auth/oidc Content-Type: application/json {"id_token": "eyJ...", "nonce": "the nonce it sent", "label": "Phone of Alice"} -
lbsauth verifies signature, issuer, expiry, audience =
OIDC_NATIVE_CLIENT_ID(tokens of the web client are refused),iatat most 10 minutes old (NativeMaxTokenAge), the nonce if sent, records the token as used (each ID token can be exchanged once), then applies the sameNotBefore, group and linking rules as a browser login, and answers:{"token": "myapp_...", "token_type": "Bearer", "expires_at": "...", "user": {"id": "...", "name": "...", "email": "...", "roles": ["user"]}}Errors:
401invalid, too old, already used or stale token (log in again),403no app group or unverified email,409subject conflict,503provider unreachable; body{"error": "..."}. -
The app stores only the app token (secure storage), drops the provider tokens, and sends
Authorization: Bearer myapp_...to/api/v1/...routes behindRequireUser/RequireRole. -
On sign-out it calls
POST /api/v1/auth/logoutwith the token. When the token expires (401) it repeats the login.
7. Revocation ("log out everywhere")
auth.RevokeUser(ctx, userID) logs a user out everywhere, atomically:
deletes all their sessions and bearer tokens, increments their epoch (so a
login in flight cannot store a working credential) and sets NotBefore to
now (rounded up to the next second), so ID tokens issued before the
revocation cannot be exchanged. The user can log in again with a fresh login
if the provider still lets them. auth.RevokeHandler() exposes it for the admin UI: it reads the user
ID from the {id} path wildcard, requires AdminRole, and answers 303 to a
local next form value or 204:
<form method="post" action="/admin/users/{{id}}/logout-everywhere">
<input type="hidden" name="next" value="/admin/users">
<button>Log out everywhere</button>
</form>
Removing someone from a provider group: remove the group, then press the button (otherwise it takes effect at their next login or session expiry). App-managed API tokens (CLI/MCP/devices) are separate and revoked by the app.
sqlitestore adds admin-view helpers: ListUsers, DeleteUser,
CountActiveSessions, ListBearerTokens.
8. Break-glass admin login
For when the provider is down. Set <APP>_ADMIN_PASSWORD_HASH (from
hash-password, min. 10 characters). GET /auth/admin-login serves a bare
form (or post a password field from your own); POST logs in as the local
user "Break-glass admin" (subject lbsauth:break-glass, roles exactly
AdminRole) with a 12-hour session (BreakGlassSessionTTL). Every success
and failure is logged at warn level.
Limits: 10 attempts per 15 minutes per client IP (IPv6 per /64), 60 per
minute overall, at most 2 argon2 checks at a time; excess gets 429 with
Retry-After. The client IP comes from X-Forwarded-For only when the peer
is in TRUSTED_PROXY_CIDRS. The provider is not needed at startup: discovery
happens on the first OIDC login, so break-glass works while it is down.
9. Storage
Implement lbsauth.Store on your own schema, or use sqlitestore:
sqlitestore.New(ctx, db)addslbsauth_*tables to the app's existing database (one file to back up). Migrations are embedded and tracked inlbsauth_schema, soPRAGMA user_versionstays the app's.sqlitestore.Pending(ctx, db)tells the app whether to take its pre-migration snapshot first. Open the handle withforeign_keys=ON.sqlitestore.Open(ctx, path)opens a separate file (WAL, foreign keys, busy timeout, single connection).
Schema history (all additive; tables already in deployed databases are only ever altered by new numbered migrations):
| Migration | Release | Adds |
|---|---|---|
0001_init |
v0.1.0 | users, sessions, bearer tokens |
0002_revocation |
v0.1.1 | epoch and not_before on users, epoch on sessions and bearer tokens, lbsauth_used_id_tokens |
A custom Store must return lbsauth.ErrNotFound / lbsauth.ErrConflict,
enforce the uniqueness listed in the Store docs, and make UpdateUser a
single atomic compare-and-set and RevokeUser one transaction: the
concurrency guarantees above rest on that. Run your app's tests against it
with oidctest (below).
10. Testing your app
oidctest.New() starts an in-process provider (discovery, JWKS, PKCE-enforcing
token endpoint) that logs in whatever user you set:
idp := oidctest.New()
defer idp.Close()
idp.AddClient(oidctest.Client{ID: "web", Secret: "s", RedirectURIs: []string{appURL + "/auth/callback"}})
idp.SetUser(oidctest.User{Subject: "u1", Email: "a@example.com", EmailVerified: true, Groups: []string{"myapp-user"}})
cfg.Issuer, cfg.HTTPClient = idp.Issuer, idp.HTTPClient()
// browser flow: GET /auth/login with a cookie-jar client that follows redirects
// native flow: idp.IDToken(nativeClientID, user, nonce)
Security notes
- Keep the app's and the provider's clocks in sync (NTP).
NotBeforeset by a revocation uses the app's clock and is compared with the provider'siat: if the provider runs behind, users cannot log in again for that long after a revocation; if it runs ahead, tokens issued just before the revocation are still accepted. - Build apps with a patched Go toolchain (lbsauth's own
go.modsaystoolchain go1.27.2; that line does not carry over to importers, so set it, and the Docker builder image, in the app).
Upgrading from v0.1.0
go get git.lbsfilm.at/lbsadmin/lbsauth@v0.1.1. No API change for apps that usesqlitestoreand the handlers/middleware.sqlitestoreapplies migration0002_revocationat startup;sqlitestore.Pendingreports it, so apps that snapshot before migrating take a pre-migration backup. Existing sessions and tokens stay valid.- Behaviour changes: a user without any role no longer authenticates; each native ID token can be exchanged once; logins with an ID token older than the last revocation or role change get 401.
- Custom
Storeimplementations:UpdateUsernow takes aUserUpdate(compare-and-set),DeleteUserCredentialsbecameRevokeUser(ctx, userID, notBefore, clearRoles),ConsumeIDTokenis new,User/Session/BearerTokengainedEpoch(andUser.NotBefore).
Development
| Command | What |
|---|---|
mage test (default) |
go test -race ./... |
mage lint |
gofmt, go vet, staticcheck, sqlc diff |
mage vuln |
govulncheck (needs network) |
mage check |
test + lint + vuln, before tagging a release |
mage gen |
regenerate sqlitestore/internal/db from sqlitestore/queries + sqlitestore/migrations |
mage build |
gen + go build ./... |
mage demo |
run example/ with the fake provider on http://localhost:8080 |
go run mage.go <target> works without mage installed. sqlc, staticcheck
and govulncheck are pinned in tools.mod (run as go tool -modfile=tools.mod ...) so apps importing lbsauth do not inherit their
dependencies. The toolchain go1.27.2 line makes the go command use a
patched release for everything in this repo.
The example app (example/) runs against a real provider with the env above
(EXAMPLE_ADMIN_PASSWORD_HASH for break-glass), or self-contained with
go run ./example -fake-idp.