Auth lib for my OIDC setup
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-10 22:59:16 +02:00
example Initial version 2026-10-10 21:58:55 +02:00
oidctest Astra sec findings 2026-10-10 22:59:16 +02:00
sqlitestore Astra sec findings 2026-10-10 22:59:16 +02:00
.gitignore Initial version 2026-10-10 21:58:55 +02:00
auth.go Initial version 2026-10-10 21:58:55 +02:00
breakglass.go Astra sec findings 2026-10-10 22:59:16 +02:00
breakglass_test.go Initial version 2026-10-10 21:58:55 +02:00
CLAUDE.md Astra sec findings 2026-10-10 22:59:16 +02:00
config.go Initial version 2026-10-10 21:58:55 +02:00
doc.go Initial version 2026-10-10 21:58:55 +02:00
errors.go Astra sec findings 2026-10-10 22:59:16 +02:00
export_test.go Initial version 2026-10-10 21:58:55 +02:00
go.mod Astra sec findings 2026-10-10 22:59:16 +02:00
go.sum Initial version 2026-10-10 21:58:55 +02:00
harness_test.go Astra sec findings 2026-10-10 22:59:16 +02:00
login_test.go Astra sec findings 2026-10-10 22:59:16 +02:00
mage.go Initial version 2026-10-10 21:58:55 +02:00
magefile.go Astra sec findings 2026-10-10 22:59:16 +02:00
middleware.go Initial version 2026-10-10 21:58:55 +02:00
native.go Astra sec findings 2026-10-10 22:59:16 +02:00
native_test.go Astra sec findings 2026-10-10 22:59:16 +02:00
oidc.go Astra sec findings 2026-10-10 22:59:16 +02:00
password.go Initial version 2026-10-10 21:58:55 +02:00
ratelimit.go Initial version 2026-10-10 21:58:55 +02:00
README.md Astra sec findings 2026-10-10 22:59:16 +02:00
security_test.go Astra sec findings 2026-10-10 22:59:16 +02:00
session.go Astra sec findings 2026-10-10 22:59:16 +02:00
sqlc.yaml Initial version 2026-10-10 21:58:55 +02:00
store.go Astra sec findings 2026-10-10 22:59:16 +02:00
token.go Initial version 2026-10-10 21:58:55 +02:00
tools.mod Astra sec findings 2026-10-10 22:59:16 +02:00
tools.sum Astra sec findings 2026-10-10 22:59:16 +02:00
unit_test.go Initial version 2026-10-10 21:58:55 +02:00
users.go Astra sec findings 2026-10-10 22:59:16 +02:00

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), state and nonce, 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 / RequireRole middleware 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.
  • RevokeUser and an admin handler for "log out everywhere".
  • A Store interface 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:

  1. Name: myapp. Type: confidential (the app has a backend).
  2. Callback URL: https://myapp.example.com/auth/callback (= PUBLIC_BASE_URL + /auth/callback, exact match).
  3. Create, then copy the client ID and the client secret (shown once) into OIDC_CLIENT_ID / OIDC_CLIENT_SECRET.
  4. 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:

  1. Name: myapp-ios. Type: public (no secret; PKCE).
  2. Callback URL: the app's custom-scheme redirect, e.g. com.example.myapp:/oauthredirect.
  3. Copy the client ID into OIDC_NATIVE_CLIENT_ID.
  4. 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>_session with an http:// 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; RunPruner deletes 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>_login and is single-use.
  • Bearer tokens for native apps: <app>_ + 32 random bytes, only the SHA-256 stored, default 30 days (BearerTokenTTL), accepted only in Authorization: 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 (from Config.CrossOriginProtection, default trusting PUBLIC_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:

  1. Stale assertions are refused (401). Each user has a NotBefore; an ID token whose iat is 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's iat). An older ID token therefore cannot restore removed access or roll roles back.
  2. 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).
  3. A user with this sub exists: update name, roles, last login, and the email if it is verified and not taken by another user.
  4. Else a user with this email exists and has no sub yet: link it (store the sub) only if email_verified is true. Unverified: rejected (403). The email belongs to a user with a different sub: rejected (409).
  5. 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

  1. The app runs the authorization code flow with PKCE against the provider as the public native client (e.g. flutter_appauth), scopes openid profile email groups, ideally with its own nonce.

  2. 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"}
    
  3. lbsauth verifies signature, issuer, expiry, audience = OIDC_NATIVE_CLIENT_ID (tokens of the web client are refused), iat at most 10 minutes old (NativeMaxTokenAge), the nonce if sent, records the token as used (each ID token can be exchanged once), then applies the same NotBefore, group and linking rules as a browser login, and answers:

    {"token": "myapp_...", "token_type": "Bearer", "expires_at": "...",
     "user": {"id": "...", "name": "...", "email": "...", "roles": ["user"]}}
    

    Errors: 401 invalid, too old, already used or stale token (log in again), 403 no app group or unverified email, 409 subject conflict, 503 provider unreachable; body {"error": "..."}.

  4. The app stores only the app token (secure storage), drops the provider tokens, and sends Authorization: Bearer myapp_... to /api/v1/... routes behind RequireUser/RequireRole.

  5. On sign-out it calls POST /api/v1/auth/logout with 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) adds lbsauth_* tables to the app's existing database (one file to back up). Migrations are embedded and tracked in lbsauth_schema, so PRAGMA user_version stays the app's. sqlitestore.Pending(ctx, db) tells the app whether to take its pre-migration snapshot first. Open the handle with foreign_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). NotBefore set by a revocation uses the app's clock and is compared with the provider's iat: 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.mod says toolchain 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 use sqlitestore and the handlers/middleware.
  • sqlitestore applies migration 0002_revocation at startup; sqlitestore.Pending reports 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 Store implementations: UpdateUser now takes a UserUpdate (compare-and-set), DeleteUserCredentials became RevokeUser(ctx, userID, notBefore, clearRoles), ConsumeIDToken is new, User/Session/BearerToken gained Epoch (and User.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.