Authentication

GoFastr's auth subsystem lives in battery/auth. It is built on a small AuthManager plus a set of plugins. The manager owns shared state (user store, session store, JWT settings, rate-limit config); plugins own individual authentication methods. Every plugin is opt-in: a service that only needs OAuth and 2FA never compiles the password-reset code in.

The lower-level primitives live in battery/auth alongside the plugins that use them: password hashing (bcrypt by default, argon2id optional; set auth.DefaultHasher before Init), the rate-limit Guard (ratelimit.go), and the session/token stores. They are dependencies of the plugins, not a second API to reach for directly. Apps wire the AuthManager and its plugins.

Quickstart

31 lines
mgr := auth.New(auth.AuthConfig{
    JWTSecret:    "your-jwt-secret-here",
    UserStore:    myUserStore,        // implements auth.UserStore
    SessionStore: mySessionStore,     // optional; in-memory default
    LoginRateLimit: &auth.RateLimiterConfig{
        MaxAttempts:   10,
        Window:        15 * time.Minute,
        BlockDuration: 30 * time.Minute,
    },
})
mgr.Use(auth.NewCorePlugin().WithTwoFactorURL("/login/2fa")) // /auth/{login,register,me,logout}; the URL is required next to the 2FA plugin
mgr.Use(auth.NewMagicLinkPlugin(auth.MagicLinkConfig{
    BaseURL:      "https://app.example.com",
    EmailSender:  mySender,
    TwoFactorURL: "/login/2fa", // required next to the 2FA plugin
}))
mgr.Use(auth.NewTwoFAPlugin(auth.TwoFAConfig{}))
mgr.Use(auth.NewAccountsPlugin())                           // /auth/accounts, /auth/unlink/{provider}
mgr.Use(auth.NewEmailVerificationPlugin(auth.EmailVerificationConfig{
    BaseURL:     "https://app.example.com",
    EmailSender: mySender,
}))
mgr.Use(auth.NewPasswordResetPlugin(auth.PasswordResetConfig{
    BaseURL:     "https://app.example.com",
    EmailSender: mySender,
}))

if err := mgr.Init(nil); err != nil {
    return err
}
mgr.RegisterRoutes(app.Router())

AuthConfig.defaults() runs automatically inside New. In production posture (DevMode left false) it picks the __Host-session cookie name, sets Secure=true, and a seven-day session TTL.

When DevMode: true and JWTSecret is empty, New mints a random per-process secret and logs a WARN. That lets demo/boilerplate apps skip the "change-me" literal: sessions invalidate on restart, which is the right trade for dev.

In production mode (DevMode: false) JWTSecret is mandatory: Init fails closed with auth: production mode requires AuthConfig.JWTSecret — set it from your secret store, or set DevMode: true for local development, and App.Start refuses to boot. There is no warn-and-continue path: an empty HMAC key would make every JWT forgeable.

Rotating JWTSecret

Rotating AuthConfig.JWTSecret no longer invalidates every session at once. List the retiring secret in JWTPreviousSecrets; new tokens are signed with JWTSecret, and tokens signed by any previous secret still verify for the drain window. The idiom mirrors the CSRF AdditionalKeys rotation path.

  1. Set JWTSecret to the new secret and move the old one into JWTPreviousSecrets. Deploy across every replica.
  2. Hold both for the drain window: one JWTExpiry (default 1h).
  3. Once every pre-rotation token has expired, remove the old secret from JWTPreviousSecrets and redeploy.
5 lines
mgr := auth.New(auth.AuthConfig{
    JWTSecret:          newSecret,            // signs new tokens
    JWTPreviousSecrets: []string{oldSecret},  // verify-only during the drain
    // ...rest unchanged
})

Production mode still requires a non-empty JWTSecret: a previous-only configuration (JWTPreviousSecrets set, JWTSecret empty) is rejected at Init, because a verify-only setup cannot sign new tokens.

Password hashing

auth.DefaultHasher hashes every new password. It is auth.BcryptHasher{} by default; set it before Init:

1 lines
auth.DefaultHasher = auth.Argon2Hasher{} // argon2id: 64 MiB, t=3, p=2
  • CheckPassword reads the stored hash's prefix: $argon2id$ verifies with argon2id, anything else with bcrypt. Rows hashed before a switch keep signing in.
  • A custom PasswordHasher (a pepper, scrypt) works too: sign-in and password change ask its Verify first, then fall back to the prefix rule, so old bcrypt and argon2id rows still verify. CheckPassword itself knows only the two built-in formats, so a custom Verify may call it for legacy rows. A wrong password under a peppered bcrypt hasher costs two bcrypt runs, the custom one and the fallback; an unknown address costs the same.
  • Argon2Hasher.Hash returns an error for parameters past what its verifier accepts from a stored hash (1 GiB of memory, 100 iterations, 16 threads, a 128-byte key). A hash made with them could never sign in.
  • Accounts created without a password (OAuth, magic link) and cleared passwords store a hash of a random secret made by DefaultHasher. A login against them costs what a login against an unknown address costs, so timing does not show which addresses hold a passwordless account. Rows written before the switch keep their bcrypt placeholder.
  • ValidatePasswordStrength refuses fewer than RecommendedMinPasswordBytes (8) characters. It counts characters, not bytes: "🔑🔑" is two.

Changing the password

POST /auth/password takes {"current_password": "…", "password": "…"} (JSON only) from a signed-in user and replaces their password. A form also sends confirm_password, which must equal password. It refuses:

  • a request with no session (401), an API token or an embed grant (401): only the interactive session behind the cookie changes it;
  • a session still owed its second factor (403), whether pending or minted before the user enrolled one;
  • a cross-site form post (rejectCrossSiteForm);
  • a wrong current password, a new one with fewer than RecommendedMinPasswordBytes characters or longer than 128 bytes, and a new one equal to the current one, and a confirmation that differs. These answer 422 with the form-errors envelope, {"error", "fields": {<field>: [message]}}, so a form draws the message beside its field.

On success the user's other sessions are revoked (session.revoked, reason=password_change), their outstanding reset links are spent (reset_tokens.purged), every magic link mailed to them is spent (magiclink_tokens.purged), and password.changed is emitted. A failed magic-link purge is logged and the change still stands. A failed session purge answers 500 auth.change_incomplete: the new password is set, but the other sessions may still work, so change it again. A reset or another change that lands while the new hash is computed wins: the change answers 422 on current_password, as for a wrong one (password.change_failed, reason=credential_changed). The caller gets a fresh session cookie that keeps the second factor the old session had passed; the response is {"updated": true}. A reset, another change or an account claim that finishes after this change revoked the sessions overtakes the fresh one: once it is stored the change re-reads the password hash, and when the hash is no longer the one it wrote, it deletes the fresh session and clears the cookie, so the caller signs in again. API tokens stay: the user minted them on purpose and revokes them under /auth/tokens. A failed current-password check emits password.change_failed.

A JWT from JWTAuth with a user store carries a tag derived from the password hash it was issued against, checked on every request. A password change, a reset, and a mailbox claim of the account each write a new hash, so every JWT issued before them answers 401 from then on. The check costs one indexed lookup per JWT request, and JWTs issued by a version without the tag answer 401 once after the upgrade. A login that a reset overtakes between the password check and the new session fails with the ordinary invalid-credentials 401 and leaves no session.

The plugins

PluginRoutesNotes
CorePluginPOST /auth/{login,register,logout,password}, GET /auth/meThe base. Always register first. Mints a PendingTwoFactor session if any registered plugin reports the user has 2FA enabled. Next to a 2FA plugin, Init requires NewCorePlugin().WithTwoFactorURL(path): an HTML form login of a 2FA account redirects there instead of to its next page, which would refuse the pending session (a JSON login answers two_factor_required). POST /auth/password is a signed-in user's own password change (see Changing the password).
MagicLinkPluginPOST /auth/magic-link/send, GET /auth/magic-link/verifyPasswordless email-link sign-in. Auto-creates users on first verify and marks them verified. On an existing account whose address is unverified it claims the account first (see Proving the mailbox claims an unverified account). Needs EmailVerifiedChecker on the store for existing accounts. Refuses to operate without EmailSender unless DevMode is explicitly set. Next to a 2FA plugin, Init requires TwoFactorURL: a 2FA account's sign-in is pending and goes there instead of OnSuccessURL (see Following a magic link does not sign you in; confirming does). TokenLength is deprecated and does nothing: the stores mint 32 random bytes.
OAuth2PluginGET /auth/oauth/{provider}, GET /auth/oauth/{provider}/callbackOAuth 2.0 (Google + GitHub built in). Requires a UserStore that implements OAuthLinker (EntityUserStore does); it fails Init closed otherwise. Binds identity by (provider, providerID) and never trusts an unverified email. Next to a 2FA plugin, Init requires OAuth2Config.TwoFactorURL: a 2FA account's sign-in is pending and goes there instead of /. See OAuth identity linking.
TwoFAPluginPOST /auth/2fa/{enroll,verify,challenge,disable,backup-codes}TOTP + backup codes. Codes are six digits; TwoFAConfig.Period (default 30 s) sets the time step, and the enrolment URL carries it to the authenticator app. Provides RequireTwoFA middleware; CorePlugin checks HasTwoFactorEnabled at login to set Session.PendingTwoFactor.
AccountsPluginGET /auth/accounts, DELETE /auth/unlink/{provider}List and unlink linked OAuth identities. Refuses to unlink the user's last login method (checks HasPassword + remaining linked accounts).
EmailVerificationPluginPOST /auth/send-verification, GET /auth/verify-emailIssues a token, redeems it, calls MarkEmailVerified on the store. The link only verifies when opened in a browser holding a full session of the account that requested it: 401 without one (the token stays live), 403 for another account's session. A link clicked in webmail arrives cross-site, where the browser withholds the SameSite=Strict session cookie; that click gets a page from this origin that opens the same link again, and the reopened link carries the cookie. The page redeems nothing. Whoever registered an address can mail the link to its owner, and the owner's click or a mail scanner's prefetch must not verify the registrant's account.
PasswordResetPluginPOST /auth/forgot-password, POST /auth/reset-passwordForgot-password always returns 200 (no enumeration). Calls SetPassword on the store, then revokes the user's other reset links, every passkey, every session (SessionUserPurger), every outstanding magic link (magiclink_tokens.purged), every API token issued through the TokensPlugin store (token.revoked, reason=password_reset) and every OAuth link with its stored provider tokens (oauth.unlinked, reason=password_reset), so an IdP identity linked from a stolen session cannot sign back in. If any of these stores errors, the reset still runs the other purges, then answers 500 and asks for another link instead of reporting success. On success it mails the account's address a notice naming the unlinked providers (NoticeTemplate overrides the body). On an account whose address was unverified it also claims the account and marks the address verified.
PasskeyPluginPOST /auth/passkey/{login/options,login,register/options,register,delete}, GET /auth/passkey/listPasskey (WebAuthn) sign-in and self-service enrolment. Sign-in needs no username. A passkey sign-in still stops at the second factor when the account has one. See Passkeys.
TokensPluginPOST/GET /auth/tokens, DELETE /auth/tokens/{id}Self-service scoped API tokens (PATs) for logged-in users. Owner forced from the session; plaintext shown once. See Service accounts & API tokens.

Each plugin's RegisterRoutes mounts under AuthConfig.BasePath (default /auth). The AuthManager reports that prefix to the embed host (framework.EmbedReserving), so no embed surface can sit over or reach a relocated BasePath either.

All built-in auth routes return 403 for calls marked by App.InProcessHandler(). This includes logout and the session-based /auth/me, 2FA enroll/verify/challenge/disable/backup-codes, passkey sign-in and management, password change, API-token create/list/revoke, OAuth sign-in/link/callback, linked-account listing/unlinking, email-verification send/redeem, login/registration, password reset, and magic-link send/confirmation/sign-in. These operations act on browser sessions or account credentials; neither a context user nor a 2FA assurance fact is a browser session. The 2FA step-up session gate also returns 403 for a marked call. RequireTwoFA remains available to gate ordinary application routes reached through Local.

The three link-minting plugins (MagicLinkPlugin, EmailVerificationPlugin, PasswordResetPlugin) validate their BaseURL at Init: it must be an absolute http/https URL (or empty, for relative links). A scheme-relative or bare-host value fails boot next to the declaration instead of shipping inside an emailed reset, verify, or sign-in link.

PasswordResetPlugin sends its email off the request path, so a known address answers as fast as an unknown one. Forgot-password puts the message on a bounded queue (1,024 entries) drained by eight workers; BodyTemplate and EmailSender.Send run on a worker, each send with a 30-second deadline. The client already has its 200 by then, so a delivery can be dropped, with an email delivery queue full or email delivery stopped warning (plugin="password-reset" and the hashed address only), when the queue is full or the plugin has stopped. PasswordResetPlugin.OnStop (an AuthPluginOnStop, so AuthManager.OnStop calls it at app shutdown) cancels sends in flight, closes the queue and waits for the workers, bounded by the context it is given. Once stopped, the plugin queues nothing more and starts no new workers. MagicLinkPlugin.OnStop likewise stops its expired-token reaper, and an OnStart after OnStop is refused.

What the host app implements

auth.UserStore is the only required interface. It maps email/ID to auth.User and creates new users:

6 lines
type UserStore interface {
    FindByEmail(ctx, email) (User, hashedPassword string, error)
    FindByID(ctx, id) (User, error)
    CreateUser(ctx, email, hashedPassword, roles) (User, error)
    UpdateRoles(ctx, userID string, roles []string) error
}

Return auth.ErrUserNotFound from the FindBy* methods when no row matches. Return auth.ErrEmailTaken from CreateUser on a unique- violation. Any other error is treated as a transport failure and propagated: plugins refuse to silently auto-create users when they can't tell "not found" from "DB unreachable".

auth.EntityUserStore is a ready-made implementation that adapts a database table (SQLite or PostgreSQL) through the framework's entity system. Use it unless you have a reason not to.

Optional extension interfaces

Plugins enable extra behaviour when the host's store implements an optional interface. Stores that don't, fall back to a documented safe-but-reduced path.

InterfaceUsed byEffect when implemented
OAuthLinkerOAuth2PluginRequired for production OAuth login: binds identity to (provider, providerID). EntityUserStore implements it (creates a <user_table>_oauth_links table on EnsureSchema). Without it, OAuth2Plugin.Init fails closed in production. The legacy email-only fallback is gone because an IdP emitting an unverified email could otherwise sign in as an existing account.
OAuthEnrichedLinkerOAuth2PluginPersist profile fields (name, avatar, email) so AccountsPlugin can return them in /auth/accounts.
OAuthProfileRefresherOAuth2PluginUpdate the profile of an existing link on a returning sign-in, never inserting one. EntityUserStore implements it. Without it the profile stays as it was at link time.
OAuthUserCreatorOAuth2Plugin, MagicLinkPluginRecord at creation time that the user has no password. Lets PasswordChecker.HasPassword return false correctly.
OAuthTokenRefresherRefreshOAuthToken / ValidOAuthTokenExchange a stored refresh token for a fresh access token. Implemented by GoogleProvider and GitHubProvider. See "OAuth token store + refresh".
AccountListerAccountsPluginPower GET /auth/accounts. Required.
AccountUnlinkerAccountsPluginPower DELETE /auth/unlink/{provider}. Required.
PasswordCheckerAccountsPluginRefuse unlink-of-last-credential correctly. Without this, the unlink check falls back to "must leave at least one linked OAuth account remaining"; fine when the user has linked accounts, less accurate when they only have a password.
EmailVerifierEmailVerificationPlugin, MagicLinkPlugin, OAuth2Plugin, PasswordResetPluginSet the email_verified flag. Required by the verification plugin and by every account claim.
EmailVerifiedCheckerMagicLinkPlugin, OAuth2Plugin, PasswordResetPluginReport whether the address was proven. Magic link refuses an existing account (500) on a store without it, because it cannot tell an owner from a squatter. OAuth treats its absence as unverified and never auto-links. Reset skips the claim.
PasswordClearerMagicLinkPluginRemove a squatter's password during a claim (placeholder hash, password_set = false). Without it the claim falls back to PasswordSetter with the placeholder hash.
PasswordSetterPasswordResetPluginPersist the new bcrypt hash. Required.
PasswordSwapperPOST /auth/passwordWrite the new hash only while the stored one is still the hash the change verified, in one statement. EntityUserStore implements it. Without it the change re-reads the hash just before writing, which leaves a short window a reset can land in.
PasswordCheckerAccountsPluginRefuse unlink-of-last-credential correctly. Without this, the unlink check falls back to "must leave at least one linked OAuth account remaining"; fine when the user has linked accounts, less accurate when they only have a password.
AtomicUnlinkerAccountsPluginDecide and apply the last-credential guard in ONE store operation, so the refuse-the-last invariant holds when two unlinks race (two concurrent DELETEs of a two-method OAuth-only account cannot both win). EntityUserStore implements it (FOR UPDATE transaction on Postgres, single conditional DELETE on SQLite); stores without it keep the check-then-act fallback, which holds per request but not across concurrent ones.
TwoFactorCheckerCorePluginPlugin-side signal: this user has 2FA enabled. TwoFAPlugin implements it. Custom plugins (WebAuthn, SMS) can implement it too.
NameStoreHost code (the admin's account page and menu)Keep an optional display name: UserName reads it ("" when none) and SetUserName stores it after auth.CleanName (trimmed, valid UTF-8, at most MaxNameRunes runes, no control, bidi or zero-width characters; ErrInvalidName otherwise; "" clears it). EntityUserStore implements it over a name column (UserFieldMap.Name) that EnsureSchema adds to an existing table as ''.
UserListerHost code (AuthManager.ListUsers)Enumerate accounts for a back-office. Returns ErrListUsersUnsupported when absent, so a missing implementation fails loudly instead of returning an empty list. See Listing users.

The EntityUserStore, EntitySessionStore, and EntityTwoFAStore provided in this package implement every relevant interface; if you start from EntityUserStore you get the full feature matrix.

mgr.JWT() is ready as soon as auth.New returns (when JWTSecret is set, or DevMode minted one), so RequireAuth(mgr.JWT()) can be wired before App.Start runs Init. RequireAuth(nil) panics at wiring time. A request with no bearer token passes only when a verified embed grant or App.InProcessHandler() admitted it and the context already holds a non-nil user; every other one gets 401. A zero AuthConfig.JWTExpiry means one hour; Init refuses a negative one.

A zero AuthConfig.SessionTTL means seven days. A negative one is a sign or unit error, and AuthManager.Init refuses it, so the app does not boot with a session lifetime nobody chose. The stores refuse one too when a caller passes it to MintSession directly: EntitySessionStore.Create rejects ttl <= 0, and MemorySessionStore.Create rejects ttl < 0 while keeping ttl == 0 as its one-week default.

The default stores are in-memory: fine for dev and tests, a trap in production: sessions vanish on restart and never resolve on a second replica, and in-memory 2FA enrollment reverts accounts to password-only auth after a restart. Production mode enforces this at Init: both the in-memory session store and the in-memory 2FA store fail Init (a warn-only boot lets a broken deployment go unnoticed). Wire the durable store:

17 lines
store, err := auth.NewEntityTwoFAStore(db, "auth_twofa", auth.EntityTwoFAStoreConfig{
    // Seals the TOTP secret at rest (AES-GCM, same sealer as the OAuth
    // token store). Rows written before the key was added still verify;
    // new writes are sealed.
    EncryptionKey: keyFromYourSecretManager,
    // Optional rotation drain window (the JWT PreviousSecrets idiom):
    // rows sealed under a retired key still open, are re-sealed under
    // the CURRENT key on that read, and stop needing the old key — so
    // drop old keys after one pass over the enrolled rows. Without a
    // matching key a sealed row FAILS CLOSED with an explicit error
    // naming the rotation instead of silently serving ciphertext.
    PreviousEncryptionKeys: [][]byte{keyRetiredLastQuarter},
})
if err != nil { return err }
mgr.Use(auth.NewTwoFAPlugin(auth.TwoFAConfig{
    Store: store, // plugin creates the table
}))

or set AuthConfig.AllowInMemoryStores: true to acknowledge a deliberate single-node deployment (both stores then boot; the 2FA store still leaves a WARN trace so the downgrade stays visible). See Horizontal scaling.

Default roles for new accounts

Every newly created account, from register, magic-link auto-create, or OAuth auto-create, is stamped with AuthConfig.DefaultRoles. The default is ["user"].

4 lines
mgr := auth.New(auth.AuthConfig{
    DefaultRoles: []string{"member"}, // everyone starts as "member", not "user"
    // …
})

These are operator configuration, never request data. The registration and auto-create flows are anonymous, so honoring a client-supplied roles field would let anyone self-promote to any role. The handlers read the value through mgr.DefaultRoles() and ignore any roles key on the incoming request; role elevation is a separate admin-gated flow.

HTML form support

/auth/login, /auth/register, and /auth/logout accept both JSON and HTML-form bodies. The handler branches on Content-Type:

RequestResponse
Content-Type: application/json200 OK JSON body with {user, token} (login)
application/x-www-form-urlencoded (HTML)303 See Other with Location to ?next= or /
multipart/form-dataSame as form-urlencoded

Both form encodings are capped at 1 MiB and read the same fields (email, password, next). A body that names email or password twice answers 400 rather than letting the parser pick one, and a multipart body with a file part answers 400: no auth form takes a file. The magic-link confirmation POST reads either encoding too.

/auth/register answers one uniform response for a free and a taken address: 202 Accepted with {"accepted":true} on the JSON surface, a 303 redirect on the form surface, and no session cookie on either branch — one unauthenticated request per address must not tell an attacker whether an account exists (the same policy forgot-password and login already pin). Consequences:

  • Register no longer auto-logs-in; clients follow up with /auth/login.
  • The JSON body no longer echoes the created user.
  • A registration attempt on an existing address creates nothing and, when AuthConfig.RegisterEmailSender is wired, emails the existing holder ("someone tried to register with your address") off the timed path, through the same kind of queue password reset uses: 1,024 entries, eight workers, a 30-second deadline per send, dropped with a warning (plugin="core") when full. CorePlugin.OnStop cancels the notices in flight and waits for the workers, so a stalled provider holds at most eight sends and never outlives shutdown.
  • Both branches burn the same bcrypt work (the hash is computed before the branch), so a clock cannot tell them apart either.

Form-flow login responses set the session cookie before redirecting, so the runtime's form interceptor follows the Location header and lands the user on the next page.

Both surfaces decode credentials strictly. A body that names email or password twice, or spells one as a case-folded variant (EMAIL, PASSword), is a 400 on the JSON and the form surface alike: the two parsers used to resolve such a body to different identities (net/url keeps the first value, encoding/json the last), so the ambiguity is refused rather than resolved by parser accident. The same strictness (handler.UnmarshalStrict, the rule Bind applies) covers every JSON auth endpoint — a duplicate or case-folded key, or an unknown top-level key, is a 400 before the handler acts. Extra keys that match no field are refused, not ignored: a body a reviewer reads differently from the handler is the attack, whichever key caused it.

Open-redirect protection: the ?next= (query or form) override is honored only for same-origin paths starting with /. //evil.example and full URLs are rejected, falling back to /.

Wire a plain HTML login form like this:

6 lines
<form action="/auth/login" method="POST" enctype="application/x-www-form-urlencoded">
  <input name="email" type="email" required>
  <input name="password" type="password" required>
  <input name="next" type="hidden" value="/dashboard">
  <button type="submit">Log in</button>
</form>

No client-side JavaScript needed beyond the framework runtime.

Owner extractor: global state and its limit

battery/auth.init() installs a global owner extractor in framework/owner so any entity with OwnerField set in the process scopes by the current auth.GetCurrentUser(ctx). The extractor is process-global: one extractor per process, last-import wins. Apps that need different extractors per framework.App instance (e.g. a single process hosting two unrelated apps) can't have them today.

If you need a different identity source, call owner.SetExtractor(yourFunc) AFTER battery/auth is imported (the last call wins). Document this clearly in your app: the import-order coupling is subtle.

Safety: when an entity has OwnerField set and the extractor can't produce an owner id for the request (no auth, anonymous request, extractor disabled), CRUD refuses the request with 401 Unauthorized. There is no fail-open path: setting OwnerField makes the entity unconditionally require an authenticated owner.

battery/auth.SessionMiddleware(mgr) reads the session cookie, looks up the user, and stashes them in the request context via handler.SetUser. After it runs, auth.GetCurrentUser(ctx) returns the live User, and any entity with OwnerField set automatically scopes per-user.

Missing, invalid, expired, or pending-2FA cookies clear any pre-existing context user, so an outer tenant's identity cannot leak into an anonymous inner session. They also clear any 2FA assurance recorded by an outer SessionMiddleware. On a successful resolution the middleware replaces that assurance with the issuing AuthManager, the resolved user's id, and whether any of that user's live, non-pending session cookies on this request has completed 2FA. By default, assurance also binds to the lookup tenant, including an empty tenant id. Resolve that tenant before SessionMiddleware when a custom session or user store partitions lookup by tenant. Only known global lookup is exempt: an EntityUserStore paired with a MemorySessionStore or EntitySessionStore is recognized by its concrete types. Wrappers and other custom stores keep the enforcing default. A host may explicitly set AuthConfig.GlobalSessionLookup when both session and user lookup ignore the tenant; assurance is then about the user alone. This opt-out does not add tenant filtering. Global lookup carries no tenant binding, even when a tenant is already on the context. With these built-in stores, mount tenant.ResolveMiddleware after authentication to derive the authorization tenant without invalidating assurance or the _events identity recheck. It considers every candidate cookie, so an older unverified session cannot hide a later stepped-up session of the same user. The exceptions are a verified embed grant and a request admitted by App.InProcessHandler(): neither carries browser credentials, and each keeps the identity its trusted server-side context established. For a marked call, SessionMiddleware also preserves the outer request's recorded 2FA assurance; RequireTwoFA checks that it still belongs to its own manager and the same context user. For tenant-partitioned lookup it also requires the same lookup tenant. This is a fact captured on the outer request, not a credential, and does not re-check session expiry or revocation. Use that entry point, not App.Router(), for generated Local repositories.

An explicit handler.RecheckPrincipal recomputes assurance from the original candidate cookies for that same principal, using the original lookup tenant for tenant-partitioned stores. The identity recheck does not refuse a tenant set or changed downstream; the tenant derivation steps run afterward. It fails when none of the principal's live, non-pending sessions remain.

1 lines
app.Use(auth.SessionMiddleware(mgr))

Pair with auth.RequireSession() (or auth.RequireSession(auth.WithRedirectOnFail("/login")) for browser flows) on any route that needs a logged-in user.

On a marked call, RequireSession() accepts a non-nil server-set context user without re-reading a session or requiring 2FA assurance. It still rejects an API-token-authenticated context. It is not a replacement for RequireTwoFA when an application route requires step-up.

These gates mean a browser session: RequireSession, SessionPolicy, and RolePolicy answer 401 to a caller authenticated only by an API token (the token middleware's TokenScopes in the context is the discriminator). A scoped gfsk_ token is a leash for the JSON API, not a key to the SSR screens; a screen that should serve token callers says so with RequireAuth or a scope gate.

RequireAuth is the JWT-Bearer equivalent for network requests; it also accepts a non-nil trusted context user on a marked Local call.

JWTs validate the signature and expiry, then re-resolve the subject on every request through the manager's UserStore (JWTAuth.SetUserStore, wired by AuthManager.Init): the principal's identity AND roles come from the fresh user row, and the claims are trusted for nothing but the subject. Deletion, erasure, and role downgrades therefore take effect on the next request, not at token TTL — a deleted owner answers the same 401 as an invalid token, and a downgraded admin's pre-downgrade JWT loses the admin routes immediately. A bare NewJWTAuth with no store (service-to-server deployments with no user table) keeps the stateless claims-derived principal; JWTSecret rotation still drains old tokens over a TTL window via PreviousSecrets.

Service accounts & scoped API tokens

The self-service token endpoints require an interactive session. An API token cannot mint another token, because that would let a scoped token escape its own leash, and neither can an embeddable surface's grant, for the same reason and more so: a grant is delegated authority, bounded to one surface and one origin with a deadline, sitting in a page the app does not control. Minting from it would exchange a bounded credential for an unbounded one.

The same rule covers battery/admin, the framework's MCP tools, and RequireRole. If an embedded surface legitimately needs one of those, the answer is a scope on the surface plus embeds.RequireScope, not a wider credential.

The access.Decider seam binds every gate, resource-scoped and role-scoped: a decider installed via access.DeciderMiddleware / access.WithDecider is consulted by CanResource (auto-CRUD permission gates) and by RequireRole, MCPUser, and MCPRole (with the zero Ref and the caller's roles), and a DecisionDeny refuses before the role policy runs. DecisionAbstain falls through to the role check.

A scoped API token carries its owner's roles, but RequireRole and MCPRole count a role for it only when the token also holds the role scope role:<name> (access.RoleScope; role:* and *:* cover every role), the rule framework.MCPRequireRole follows. A customers:read token an admin minted does not pass RequireRole("admin"); a token meant to call admin routes is minted with role:admin in its scopes. Sessions and JWTs carry no scopes and are unaffected. The Decider still sees the owner's full role set, so a deny keyed on a role holds either way.

Non-human identities such as CI runners, background workers, and internal scripts authenticate with API tokens (gfsk_-prefixed PATs) instead of session cookies or JWTs. Tokens are issued to a human user or to a service account: a non-interactive identity that holds roles like a user but has no mailbox and cannot log in. Both populate request context the same way sessions do, so owner-scoping and access.Can work unchanged.

Token format

  • Plaintext: gfsk_ + 40 lowercase hex chars (20 bytes from crypto/rand). The gfsk_ prefix makes leaked tokens greppable by secret scanners the way ghp_ / xoxb- are.
  • At rest: only sha256(full-plaintext) is stored, plus a display Prefix (the brand plus 7 random chars, gfsk_1a2b3c4) so listings can identify a token without revealing it. The same hash-then-lookup grammar now covers every password-equivalent secret the battery persists: magic-link, password-reset, and email-verification tokens, and the session rows of EntitySessionStore (a read-only dump of the session table is no longer a pile of live session cookies).
  • The plaintext is returned exactly once, from IssueToken or POST /auth/tokens. It is never persisted, logged, or placed in any error string or audit event.

Issuing

13 lines
ts, _ := auth.NewSQLAPITokenStore(db)
ss, _ := auth.NewSQLServiceAccountStore(db)

// Programmatic: mint a token for a user or service account.
plaintext, rec, err := auth.IssueToken(ctx, ts, auth.TokenSpec{
    Name:      "ci-deploy",           // required
    OwnerKind: "user",                // "user" | "service"
    OwnerID:   currentUser.GetID(),
    Scopes:    []string{"posts:read", "deploys:run"},
    TTL:       30 * 24 * time.Hour,   // 0 = no expiry
})
// Store `plaintext` in your secret manager NOW. It cannot be retrieved again.
_ = rec // {ID, Name, Prefix, Scopes, ExpiresAt, …}

Validation: Name/OwnerKind/OwnerID required; OwnerKind ∈ {user, service}; scopes match ^[a-z0-9_*-]+:[a-z0-9_*-]+$ (so *:read and *:* mint); max 32 scopes. POST /auth/tokens answers 400 for a ttl_seconds below zero or above 9223372036 (about 292 years, the largest that fits a time.Duration).

A user-minted token has no default expiry. An omitted ttl_seconds mints a token that never expires — the strongest credential the endpoint can hand out, by design (TTL: 0 means "no expiry" in TokenSpec too). Every other credential in this battery carries a bounded default (sessions 7d, JWTs 1h, magic links 15m, reset tokens 1h, verification tokens 24h). To bound user tokens, set an explicit TTL at mint time, or layer a sweep of your own over ExpiresAt. A leak with no expiry has no natural remediation horizon; revocation depends on an operator noticing.

Middleware wiring

Mount TokenMiddleware alongside SessionMiddleware. It only intercepts Authorization: Bearer gfsk_… credentials; non-gfsk_ bearers (JWTs) and header-less requests pass through untouched for the session/JWT middleware to handle. A gfsk_ credential that fails validation (unknown, revoked, expired, disabled owner) clears any prior ctx identity and proceeds anonymous: it never falls back to an outer identity.

3 lines
app.Use(auth.SessionMiddleware(mgr))
app.Use(auth.TokenMiddleware(mgr.UserStore(), svcAccounts, apiTokens,
    auth.WithTokenAudit(auditSink)))

TokenMiddleware has no AuthManager, so audit is wired with WithTokenAudit(sink); a nil sink disables auditing and never panics.

On the consumer side, the generated typed client (entity declarations) carries the token for you: set client.Token to a plaintext PAT and every request sends Authorization: Bearer gfsk_…. Bearer requests skip CSRF by design, so scripts and CLIs need no cookie or CSRF-token handling. The customer flow is: log in to the app's web UI, mint a scoped token via POST /auth/tokens (TokensPlugin), paste it into the tool once. The generated customer CLI (Ship your API as a CLI) packages exactly this flow as its login command.

Scopes

Sessions and JWTs are unscoped: a logged-in user carries their full capability. API tokens are additionally restricted by their scope list:

  • exact: "posts:read" grants "posts:read"
  • wildcard verb: "posts:*" grants any "posts:…"
  • wildcard resource: "*:read" grants read on every resource
  • global: "*:*" grants everything
  • empty scopes: the token authenticates but RequireScope always 403s

RequireScope only gates the routes you mount it on, and sessions pass it unscoped. Mount it on the machine-facing routes you actually want scope-limited, and don't rely on it as a blanket gate for human traffic.

6 lines
// In a handler: token requests are scope-checked; sessions pass.
if !auth.HasScope(ctx, "posts:read") { /* 403 */ }

// As route middleware: 403s token-authenticated requests lacking the scope;
// non-token (session/JWT) requests pass unscoped.
r.Group("/posts", auth.RequireScope("posts:write")).Post("", handler)

For the auto-CRUD tree there is a blanket gate: RequireAPIScopes(prefix) derives the required scope from the route itself: the first path segment after the prefix is the resource, GET/HEAD need <resource>:read, everything else <resource>:write, so one mount makes every minted scope real across /api:

2 lines
app.Use(auth.TokenMiddleware(users, accounts, tokens))
app.Use(auth.RequireAPIScopes("/api")) // ["customers:*"] token ⇒ 403 off /api/invoices

Every route an entity registers names the entity's table as its resource (crud.EntityRouter, built on router.ResourceGroup), and the middleware takes that name first. The CRUD routes and their subroutes (/{id}, /_batch, /_events, /llm.md), the entity's Endpoints, and EntityUI's /_bulk, /_export.csv and /_pick all answer to the table, wherever they are mounted: POST /api/v1/_batch on a table named v1 needs v1:write, never _batch:write, and an orders handler mounted at /api/orders/v1 needs orders:read. An Endpoint's HTTP route and its MCP twin ask the same <table>:<verb>.

For any other route, a version segment (v1, v2, v1.2) is skipped, so /api/v1/posts and /api/v2/posts both need posts. Whether a segment is a version or a table comes from the route that matched, not from how the path is spelled: with a table named v1 mounted at /api, GET /api/v1/orders is record orders of table v1 (route /api/v1/{id}) and needs v1:read, never orders:read. Mounted outside a router (no matched route on the request), the path alone cannot tell the two apart, so the middleware requires every candidate: v1:read and orders:read.

Without it (or per-route RequireScope), a token's scope list is advisory only: the token still authenticates as its owner everywhere. Session/JWT callers and paths outside the prefix are untouched.

The CRUD layer holds the same line when one route reaches another entity. A scoped request needs <target>:read for every ?include= target, at every depth, and for every ?rel.field= filter hop, and <target>:write for every child a cascade write creates or updates; ManyToMany link-by-id needs <target>:read. A ["customers:read"] token gets 403 on /api/customers?include=invoices, and a ["customers:write"] token cannot create invoices through a nested "invoices": [...] body. This applies wherever the CRUD routes are mounted, prefix or not. The token middleware marks the request through access.WithHeldScopes, which auth.WithTokenScopes calls; embed grants are marked the same way.

auth.TokenScopes(ctx) returns (scopes, true) only for token-authenticated requests; (nil, false) for sessions/JWT.

The token-management endpoints are session-only. POST/GET/DELETE /auth/tokens require an interactive session. A request authenticated by an API token is rejected with 401, even under the recommended global TokenMiddleware wiring. Otherwise a leaked scoped (or empty-scoped) token could mint a *:* token for its owner and escape its own scope leash, or list/revoke the owner's other tokens. Token holders manage their tokens by logging in, not with the token itself.

On an account with a second factor, the endpoints also need the cookie session that passed it, the bar passkey add and password change hold. A session that predates enrollment answers 403 auth.2fa_required, and a bearer JWT with no such session answers 401. Without this, a stolen pre-enrollment cookie turned into a permanent *:* token the new factor never touched.

A non-NULL revoked_at or expires_at that the store cannot parse (on SQLite, unixepoch() or julianday('now') written out of band) reads as the epoch: the token is revoked or expired, never live.

Service accounts (programmatic-only)

A service account authenticates only via tokens. There is no login path. Create one in Go; its roles flow to RequireRole / access.Can through the User interface. Its tokens still need the matching scope: role:deploy for RequireRole("deploy"), and the permission itself for access.Can:

3 lines
sa := auth.NewServiceAccount("ci-runner", []string{"deploy", "reader"})
ss.Create(ctx, sa)
// then auth.IssueToken with OwnerKind: "service", OwnerID: sa.ID

Service-account management has no HTTP endpoints in v1: create and disable them from trusted server code (SetDisabled).

Management endpoints (TokensPlugin)

Self-service token management for logged-in users:

EndpointEffect
POST {base}/tokensCreate a token for the caller (session user). Body {name, scopes, ttl_seconds}. Owner is forced from the session; owner_kind/owner_id in the body are ignored. Returns the plaintext once. A reset or password change that lands while the request is open (its body still unread), or one that already ended the request's session, wins: the new token is revoked and the request answers 401. The revoke runs detached from the request, so a client that hangs up cannot stop it.
GET {base}/tokensList the caller's tokens (prefix only, never the plaintext or hash).
DELETE {base}/tokens/{id}Revoke one of the caller's tokens. A foreign id is a 404 (owner-scoped).
1 lines
mgr.Use(auth.NewTokensPlugin(apiTokens))

Audit events

KindWhenMeta
token.createdPOST /auth/tokens succeedstoken (prefix), name
token.revokedDELETE /auth/tokens/{id} succeedstoken_id
token.auth_faileda gfsk_ credential fails in TokenMiddlewarereason ∈ unknown\revoked\expired\owner_missing\owner_disabled, token (prefix)

Only the token prefix ever appears in an audit event, never the credential itself.

Per-SSR-screen policies (auth.SessionPolicy, auth.RolePolicy)

For apps built with the framework/uihost SSR stack, gating happens at the screen layer, not the router middleware layer. Attach an app.Policy to a Screen or ScreenGroup and the dispatcher evaluates it before Load() runs:

23 lines
import "github.com/DonaldMurillo/gofastr/core-ui/app"

// Public marketing pages: no policy, no gate.
application.RegisterScreen(app.NewScreen("/",      &HomeScreen{}),  nil)
application.RegisterScreen(app.NewScreen("/about", &AboutScreen{}), nil)

// Gated dashboard group: every screen inherits SessionPolicy.
dash := app.NewScreenGroup("/dashboard", dashLayout, auth.SessionPolicy())
dash.Screen(app.NewScreen("home",    &Home{}),    nil)
dash.Screen(app.NewScreen("billing", &Billing{}).
    WithPolicy(auth.RolePolicy(auth.Roles("admin"))), nil)
application.Router.ScreenGroup(dash)

// Same-URL marketing/dashboard duo via RenderAlt: factory, NOT
// singleton, so each request gets a fresh instance (no cross-user
// data leak on shared alt state).
application.RegisterScreen(
    app.NewScreen("/", &Dashboard{}).
        WithPolicy(auth.SessionPolicy(auth.WithRenderAlt(
            func() component.Component { return &Landing{} },
        ))),
    nil,
)

The dispatcher resolves each request as one of four outcomes:

DecisionWhat happens
AllowNormal Load + Render.
Redirect303 to WithRedirect(url), default /login?next=<path>. A client-side navigation gets the same destination: a relative path in X-Gofastr-Location, an absolute http(s) URL (an identity provider's login) in X-Gofastr-Document, which the runtime loads as a page.
RenderAltThe alt component takes the screen's place; its Load runs. The screen's title, description and article flag stay off the page.
BlockHTTP status from WithBlock(status), default 401/403. The body is the status's phrase, or WithBlockReason(status, phrase)'s, in the request's locale.

Option precedence: last-write-wins per call, alt > redirect > block on fail

Each With* option overwrites the others' fields on the way in. If you chain auth.WithRedirect("/x").WithBlock(403) the Block wins. The second option clears the redirect URL. There is no "compose two failure outcomes"; pick one per policy.

When more than one applies (e.g. through some custom option-builder), failureDecision resolves them in order: RenderAlt first if its factory is set; otherwise Redirect if a URL is set; otherwise Block (default 401 for SessionPolicy, 403 for RolePolicy).

Defaults applied when no failure option is passed:

PolicyDefault failure outcome
SessionPolicyRedirect("/login") with ?next=<request-path>
RolePolicyBlock(403, "forbidden")

To suppress the auto-?next= on a redirect (e.g. anon "/" → "/marketing"), pass auth.NoNext():

1 lines
auth.SessionPolicy(auth.WithRedirect("/marketing", auth.NoNext()))

RenderAlt takes a factory, not an instance: the framework calls it once per request so the alt component cannot leak data across users:

3 lines
auth.SessionPolicy(auth.WithRenderAlt(
    func() component.Component { return &Landing{} },
))

Inside a RenderCtx, call auth.SessionFrom(ctx) for in-component gating (sidebar nav, conditional CTAs); no policy machinery needed for a per-widget branch:

6 lines
func (s *Header) RenderCtx(ctx context.Context) render.HTML {
    if sess, ok := auth.SessionFrom(ctx); ok {
        return AuthedHeader(sess)
    }
    return AnonHeader()
}

Pair with SessionMiddleware upstream so the policy sees the loaded user. JSON/API routes still use RequireSession middleware as before. Policies are for the SSR page layer specifically.

RolePolicy consults an installed access.Decider before its role check (the same seam RequireRole and the resource gates use): a DecisionDeny for the caller refuses the screen even when the role list alone would allow it, and DecisionAbstain falls through to the role check.

Auth entities are private by default

The user / session tables back the auth subsystem: exposing them via auto-CRUD would leak password hashes and session tokens. Use the pre-built configs:

4 lines
app.Entity("users",    auth.UserEntityConfig())     // CRUD=false, MCP=false
app.Entity("sessions", auth.SessionEntityConfig())  // CRUD=false, MCP=false
mgr.SetUserStore(auth.NewEntityUserStore(db, "users"))
mgr.SetSessionStore(auth.NewEntitySessionStore(db, "sessions"))

auth.UserEntityFields() and auth.SessionEntityFields() are still exported for hosts that want to assemble their own config, but the *EntityConfig() helpers are the safe default.

EntitySessionStore.EnsureSchema creates a unique index on token and an index on user_id (and expires_at) whoever created the table. When AutoMigrate made it from SessionEntityConfig() first, the store's own CREATE TABLE did nothing and every cookie lookup scanned the table.

A soft-deleted account is signed out. With Scope.SoftDelete on the users entity, a delete only stamps deleted_at. EntityUserStore finds that column when AuthManager.Init runs EnsureSchema (AutoMigrate has added it by then) and from then on FindByID, FindByEmail and ListUsers skip a trashed row: the password no longer signs in, and the account's sessions, API tokens and JWTs resolve to no user, so GET /auth/me answers 401. Restoring the row brings the account back. A column added by a migration after boot takes effect at the next restart.

Durable store schema and PostgreSQL first boot

EntityUserStore, EntitySessionStore, and EntityTwoFAStore create their tables when AuthManager.Init runs. On PostgreSQL their boolean columns use the native BOOLEAN type, matching the Go bool values bound by the stores. This includes auth_users.password_set, the session 2FA flags, and the durable 2FA flags.

These stores, and the <users>_oauth_links table beside the user table, name their tables and the UserFieldMap columns the way AutoMigrate stores them: folded to lowercase, then quoted (query.QuoteFolded). A host that declares its users entity with Table: "Members" and a PasswordHash field gets one members table that the entity layer and the store share. Older versions quoted the name as written, so on PostgreSQL a mixed-case name got a second, case-kept "Members" table that held the store's rows. When Init finds such a table it stops with an error naming the rename (ALTER TABLE "Members" RENAME TO members, or move the rows when members already exists) instead of booting on an empty table.

Apps generated from an app spec register a configured bootstrap admin through App.WithSeed. The hook runs after auto-migration and before the server accepts traffic. A missing ADMIN_SEED_PASSWORD, a lookup error other than auth.ErrUserNotFound, password hashing failure, or insert failure aborts startup instead of leaving a fresh app with an unusable admin login. The seed is idempotent: an existing account is left unchanged.

The auth stores also convert legacy PostgreSQL INTEGER boolean columns (0/1) to BOOLEAN during EnsureSchema, preserving the value and setting a boolean FALSE default. This self-heals tables created by older GoFastr versions. Back up production data before the first boot after upgrading. The 2FA table likewise self-heals two columns added since its first release — version (optimistic concurrency) and last_used_step (the durable TOTP single-use counter: one code authenticates one session even across restarts and replicas).

EntityUserStore adds email_verified (configurable through UserFieldMap.EmailVerified) to an existing users table with every row set to FALSE. Nothing the battery stored before said whether an address was proven, and registration never proved one, so marking old rows verified would bless every account an attacker registered under someone else's address. The cost lands once per legacy account:

  • The first magic link for a legacy account claims it. Its password is cleared, its sessions, API tokens and OAuth links are removed, and the owner signs in through the link. They set a password again through forgot-password if they want one.
  • A completed password reset claims it the same way but keeps the new password.
  • A passwordless OAuth account stops auto-linking a second provider until one of its linked providers asserts email_verified for the account's address on login, which marks it verified.
  • EmailVerificationPlugin marks an account verified without the claim, so a host can backfill accounts it trusts by its own evidence with MarkEmailVerified.

A user your own code creates, such as a bootstrap admin seeded from an operator-chosen address, is unverified too. Mark it right after CreateUser, or its first magic link claims it and clears the seeded password:

9 lines
u, err := authCfg.UserStore.CreateUser(ctx, "admin@example.com", hash, []string{"admin"})
if err != nil {
	return err
}
if v, ok := authCfg.UserStore.(auth.EmailVerifier); ok {
	if err := v.MarkEmailVerified(ctx, u.GetID()); err != nil {
		return err
	}
}

The app spec's generated admin seed does this.

AuthManager.Init also re-registers the right-to-be-forgotten erasers against the table names the CONFIGURED stores actually use (the datexport registry is last-writer-wins per name), so an app wired as NewEntityUserStore(db, "users") + NewEntitySessionStore(db, "sessions") gets its users row, sessions rows, resolved IdentityEmail, and magic-link tokens erased — not just the canonical auth_* spellings the package registers at import time. When LoginRateLimitPerAccount uses a SQLRateLimitStore, the erasure also deletes that limiter's attempt and block rows for the user's email (the key is its Scope, login_account by default, then |account: and the email); other limiters' rows on the same store stay.

Email identity is canonical (invisible-stripped + NFC-normalized + trimmed + lowercased)

Every flow that reads or stores an email — login, registration, magic links, OAuth account matching, password reset, and the login rate-limiter key — canonicalizes it with auth.CanonicalEmail at its ingestion point: invisible/bidi codepoints and C1 controls stripped (core/textsafe), then Unicode NFC normalization, then trim, then lowercase. Custom UserStore implementations therefore always receive canonical input; a host that accepts emails on its own surfaces should call auth.CanonicalEmail too so its lookups agree with the battery's.

Every email ingestion path refuses NUL bytes and malformed UTF-8 with 400 invalid_email before lookup, registration, or token creation. This storage invariant is checked before normalization and on both the input and output of AuthConfig.CanonicalizeEmail; an override cannot silently repair or introduce unstorable bytes. Other override errors retain the composed-email fallback, unless the error carries its own localized phrase.

Invisible characters: NFC cannot fold zero-width and bidi codepoints, so ow\u200Bner@example.com (zero-width space), a trailing BOM, or a right-to-left override inside the local part would otherwise register as a second account that renders identically to the first in every list, log, and mail template. The default canonicalizer strips them before folding, so the twin lands on the base account. The set includes the Unicode tag characters (U+E0000–U+E007F) and the Hangul fillers (U+115F, U+1160, U+3164, U+FFA0). auth.CleanName refuses the same set in a display name, except a subdivision flag such as Scotland's, whose tag letters stay.

Unicode normalization (NFC/NFD): the same mailbox can be spelled in composed (é) or decomposed (e + combining acute) form — macOS and iOS clipboards famously decompose copied text. Both spellings canonicalize to the composed form, so one mailbox is one identity and OIDC auto-linking never splits on the spelling. A deployment with different rules (a provider that ignores dots or plus tags, a legacy store keyed on raw bytes) installs AuthConfig.CanonicalizeEmail; it replaces the default at every ingestion point — including the invisible strip, an override owns its own invisible-character policy — and may return auth.ErrEmailNotComposed (or any error) to refuse an address, which the handlers surface as 400:

16 lines
mgr := auth.New(auth.AuthConfig{
    // ...
    // A host whose provider ignores dots in the local part folds them
    // too, on top of the default NFC + trim + lowercase.
    CanonicalizeEmail: func(e string) (string, error) {
        c, err := auth.CanonicalEmail(e)
        if err != nil {
            return "", err
        }
        local, domain, ok := strings.Cut(c, "@")
        if !ok {
            return "", auth.ErrEmailNotComposed
        }
        return strings.ReplaceAll(local, ".", "") + "@" + domain, nil
    },
})

Upgrading a store with mixed-case rows: rows written by older versions keep their original casing and will no longer match. Before rewriting, find casings that would collapse into the same account — those must be resolved by the operator (merge or delete), not by the migration:

2 lines
SELECT LOWER(TRIM(email)), COUNT(*) FROM auth_users
GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1;

With zero collisions, normalize and then keep the invariant honest with an expression index. TRIM mirrors the runtime canonicalization (CanonicalEmail trims before lowercasing) so a legacy " Bob@Example.com " row collapses to the same identity. PostgreSQL shown; SQLite supports the same expression-index form:

3 lines
UPDATE auth_users SET email = LOWER(TRIM(email));
CREATE UNIQUE INDEX auth_users_email_canonical
  ON auth_users (LOWER(TRIM(email)));

Listing users

Back-offices that need to enumerate accounts (an admin user list, a back-office screen) should call mgr.ListUsers, the supported replacement for raw SQL against auth_users. It pages through the store in a stable email-ordered sequence and returns only id/email/roles, never password_hash.

4 lines
users, total, err := mgr.ListUsers(ctx, auth.ListUsersOptions{
    Limit:  50, // <=0 → 50, capped at 500
    Offset: 0,  // <0 → 0
})

total is the full row count (independent of the page), so a UI can render "showing 1–50 of 832". There is no HTTP route; call it from trusted server code (an admin handler you mount yourself), not the auth plugin's routes.

ListUsers type-asserts the configured UserStore for the optional UserLister interface. EntityUserStore implements it; a custom store that does not gets auth.ErrListUsersUnsupported, a loud failure rather than a silently empty list, so a deployment that forgot a listable store is told explicitly.

CSRF protection

For form-submit flows, mount the CSRF middleware globally and embed the hidden field helper in every form:

1 lines
app.Use(auth.CSRF(auth.WithCSRFSecret(secret)))
4 lines
<form action="/save" method="POST" enctype="application/x-www-form-urlencoded">
  {{ csrfField .Request }}
  <input name="title">
</form>

Where csrfField is a template helper bound to auth.CSRFInputHTML(r). The middleware accepts the token either as a hidden _csrf field (HTML forms) or as the X-CSRF-Token header (XHR / fetch flows that don't go through a form).

Bearer-token requests (Authorization: Bearer …, X-API-Key: …) are skipped: they don't ride on cookies and aren't subject to CSRF. Any other Authorization scheme (Basic, Digest, Negotiate, NTLM) is checked: the browser attaches those by itself, to a cross-site form too, once a proxy or an intranet has challenged for them.

Verified embed exchanges and calls admitted by App.InProcessHandler() are also exempt. In-process admission refuses credential headers, URL userinfo, and TLS peer credentials before marking the context; network headers cannot forge that marker. This exception also applies under WithBFFPosture, not to ordinary anonymous browser writes. Custom core CSRF middleware can compose framework.IsInProcessRequest into its Skip predicate. See sdk.md.

auth.CSRFToken(r) reads the token the middleware put on the request, on a verified POST too (a form re-rendered after a validation error). Without one it reads the __Host-auth_csrf cookie before auth_csrf.

The CSRF cookie is Secure/__Host--prefixed whenever the request is HTTPS, including behind a TLS-terminating proxy that sets X-Forwarded-Proto: https (the app itself sees plain HTTP there). A name set with WithCSRFCookieName gets the prefix too.

Login/register/logout carry their own cross-site guard. Those endpoints can't rely on the CSRF cookie: a login CSRF needs no pre-existing cookie, so an attacker's page could silently log a victim into an attacker-controlled account. The guard refuses any POST a cross-site page can send without a CORS preflight: the three HTML form enctypes (application/x-www-form-urlencoded, multipart/form-data, and text/plain), plus a bodyless fetch() that carries no Content-Type at all, when Origin (or Sec-Fetch-Site: cross-site) names another site. text/plain and the missing Content-Type are the two shapes the old urlencoded/multipart-only check missed, and both are preflight-free. That is why a text/plain cross-site form could complete a magic-link sign-in before this guard widened. JSON stays exempt on purpose: a cross-site JSON POST is preflighted, and these routes answer no preflight, so a cross-origin SPA reaching them through configured CORS depends on the exemption. Requests with no Origin (curl, native apps) pass. This is on by default; no configuration required.

The same guard covers the other bodyless POSTs a signed-in user's cookie can carry: /2fa/disable, /2fa/backup-codes and /2fa/enroll refuse a cross-site request, so a hidden form on another site cannot turn off a user's second factor or replace their backup codes. /2fa/verify and /2fa/challenge read a JSON body and answer 415 to anything else.

Browser-backend (BFF) posture

For an application whose browser talks directly to the same GoFastr backend, use the explicit auth preset instead of wiring the session, CSRF, and Origin controls independently:

13 lines
mgr := auth.New(auth.AuthConfig{
    JWTSecret: os.Getenv("AUTH_JWT_SECRET"),
    UserStore: users,
    SessionStore: sessions,
})

app := framework.NewApp(
    framework.WithAPIPrefix("/api"),
    auth.WithBFFPosture(mgr, auth.BFFPostureConfig{
        AllowedOrigins: []string{"https://app.example.com"},
    }),
)
app.RegisterBattery(mgr)

The preset makes login cookie-only (no JWT in the JSON response), forces the session and CSRF cookies to Secure, installs SessionMiddleware and CSRF, and rejects non-allowlisted Origin values under the configured API prefix (/api in this example). Browser preflights receive credentialed CORS headers only for an exact allowed origin. Bearer JWTs are rejected on that API surface; gfsk_ API tokens remain available for explicit non-browser automation.

For branded API credentials set BFFPostureConfig.TokenPrefix to the same prefix as TokensPlugin.WithPrefix and TokenMiddleware(WithTokenPrefix(...)). The default is gfsk_. A prefix must be 2–16 ASCII characters including the trailing _, start with a lowercase letter, and contain only lowercase letters/digits before the _; invalid prefixes panic at configuration time. Other bearer credentials remain refused by the BFF guard.

AllowedOrigins requires exact http:// or https:// origins and rejects wildcards. The protected prefix comes from framework.AppConfig.APIPrefix so WithAPIPrefix and WithBFFPosture may appear in either option order without duplicating configuration. With no APIPrefix, CRUD mounts at the root and the guard covers the whole app, not only /api: include the app's own origin in AllowedOrigins for Origin-carrying browser requests. Requests without an Origin still pass the origin check. Because cookies are always Secure, run this posture behind HTTPS, even if AuthConfig.DevMode is true.

framework/ui.SignOut remains compatible with this preset. Its static logout form is exempt from the generic CSRF token check only at the configured auth logout path; the auth handler still rejects cross-origin form submissions. All other cookie-authenticated mutations require a CSRF token.

Naming conventions: DB columns vs. wire JSON

Mixing DB-column casing with wire-JSON casing trips up most first-time users. The rule:

LayerConventionWhere set
DB column namessnake_case (e.g. password_hash, user_id)Entity declarations + UserEntityFields()
JSON wire formatcamelCase by default (e.g. passwordHash, userId)AppConfig.JSONCase (or CrudHandler.JSONCase per handler); defaults to camelCase

The framework automatically converts snake_case DB columns to camelCase JSON keys at the response layer (via crud.JSONCase). You do NOT need to match auth's snake_case column names in your own entities: define your columns however you like at the DB layer and the wire format stays consistent.

9 lines
// Both of these expose the SAME wire format ({"userId":"...","notes":"..."}):
app.Entity("logs", entity.EntityConfig{
    Fields: []schema.Field{
        {Name: "user_id", Type: schema.String}, // snake
        {Name: "notes",   Type: schema.String},
    },
    Scope: &entity.ScopeConfig{OwnerField: "user_id"},
})
// Inside JSON payloads (POST body, response): {"userId": "...", "notes": "..."}

If you genuinely need snake_case on the wire (matching a Python or Rails client's expectations), set AppConfig.JSONCase = "snake_case".

AuthConfig.defaults() produces two postures:

  • Production (default): SessionCookie = "__Host-session", SessionSecure = true. The __Host- prefix forces the browser to reject the cookie unless Path=/, Secure, and no Domain are set, which blocks sibling-subdomain cookie injection.
  • Dev (DevMode: true): SessionCookie = "session_id", SessionSecure = false. Use only over plain HTTP in local development.

Every mint goes through one helper, and the session cookie carries SameSite=Strict on all of them: password login, logout, magic-link verify, and the OAuth callback. (The OAuth state cookie is deliberately SameSite=Lax — see "The callback is bound to the browser that started the flow" under OAuth — because a Lax cookie is what still rides the provider's top-level redirect back.)

Rate limiting

16 lines
auth.AuthConfig{
    LoginRateLimit:           &auth.RateLimiterConfig{...}, // per-IP on /auth/login
    LoginRateLimitPerAccount: &auth.RateLimiterConfig{...}, // per-email on /auth/login
    RegisterRateLimit:        &auth.RateLimiterConfig{...}, // per-IP on /auth/register
}
auth.MagicLinkConfig{
    RateLimit:        &auth.RateLimiterConfig{...}, // per-IP on /auth/magic-link/send
    AddressRateLimit: &auth.RateLimiterConfig{...}, // per-address on /auth/magic-link/send
    VerifyRateLimit:  &auth.RateLimiterConfig{...}, // per-IP on /auth/magic-link/verify
}
auth.TwoFAConfig{ RateLimit: &auth.RateLimiterConfig{...} }     // per-IP on /2fa/{verify,challenge}
auth.PasswordResetConfig{
    RateLimit:        &auth.RateLimiterConfig{...}, // per-IP on forgot/reset
    AddressRateLimit: &auth.RateLimiterConfig{...}, // per-address on /auth/forgot-password
}
auth.EmailVerificationConfig{ RateLimit: &auth.RateLimiterConfig{...} } // per-IP on send-verification

POST /auth/password spends the login limiters (per IP, and per account on the key login uses), so a stolen session guesses the current password no faster there than at the login form.

Login (per-IP + per-account) and register (per-IP) carry defaults even when you set nothing: credential stuffing and account-table flooding are network attacks, not config-mode ones. Pass a config with a large MaxAttempts to loosen, not to leave them off.

The same is true of the plugin endpoints — every one of these ships a default per-IP floor when its RateLimit is left nil:

EndpointDefault floor
POST /auth/forgot-password, POST /auth/reset-password10/min, 15-min block (register floor; token minting + mail dispatch)
POST /auth/magic-link/send10/min, 15-min block (register floor; mail dispatch)
POST /auth/magic-link/verify30/min, 5-min block (login per-IP floor; secret redemption) — knob: MagicLinkConfig.VerifyRateLimit
POST /auth/send-verification10/min, 15-min block (register floor)
POST /auth/2fa/challenge, POST /auth/2fa/verify10/min, 15-min block (6-digit code guessing needs ~333k expected attempts at skew=1)

Loosening any of them is explicit: pass the plugin's RateLimit (or VerifyRateLimit) with a large MaxAttempts.

The two endpoints that mail a link to whatever address they are given, POST /auth/magic-link/send and POST /auth/forgot-password, also limit each address however many IPs ask: one mail every two minutes (AddressRateLimit on MagicLinkConfig and PasswordResetConfig). Without it a rotating proxy could flood one inbox with links. It is a short cooldown, not an hourly budget with a long block: anyone can spend an address's budget, and a long block would let a stranger stop every link to its owner. The mails a stranger triggers still reach the owner, and each link outlives the cooldown, so the owner always holds a working one. Keep TokenTTL above Window + BlockDuration (4 minutes at the defaults) if you shorten it.

The default per-address limiter keeps its state in memory on each replica, even when the plugin's RateLimit uses a shared SQLRateLimitStore, so the cooldown holds per replica. That is deliberate: the SQL store records an attempt before it counts, so a concurrent burst on one key can deny every caller, which on this limiter would let a stranger's burst stop the owner's mail again. Put AddressRateLimit on a shared store only knowing that. Past the budget the request gets the same 200 as any other and nothing is minted or mailed, so the limit says nothing about whether the address has an account; the audit event carries throttled=address. The limiter key is address: plus the SHA-256 of the canonical address, so a shared SQLRateLimitStore holds no addresses.

/auth/2fa/challenge also counts attempts per pending session, whatever address they come from: a password holder can spread guesses over many addresses (one IPv6 /64 is plenty), so the per-IP floor alone does not bound them. After TwoFAConfig.ChallengeAttemptsPerSession codes (default 10) the next attempt deletes the session, answers 401 "too many attempts: sign in again", and emits 2fa.challenge_locked. The account is not locked: a fresh login starts a fresh budget, and the login limiters meter how often that can happen. The counter uses TwoFAConfig.RateLimit.Store when one is set, so the budget holds across replicas. If that store errors, the challenge answers 503 with Retry-After and the pending session is kept.

Minted tokens are reaped automatically. Rate limits bound the rate of anonymous mints, not the total; unredeemed magic-link, password-reset, and email-verification rows would otherwise accumulate forever. The SQL token store (NewSQLMagicLinkTokenStore) sweeps expired rows on its own mint path (at most once per 15 minutes), and the magic-link plugin runs a cleanup ticker from OnStart — no operator cron, no scheduled Cleanup calls to wire. A failed sweep is logged and the mint still returns its token (the row is already written); a failed sweep is tried again a minute later rather than on every mint, so a failing DELETE does not run on each one.

X-Forwarded-For is not trusted by default. Set RateLimiterConfig.TrustForwardedFor = true only when your service runs behind a reverse proxy you control. Without one, an attacker rotates the header per request and bypasses every per-IP limit. The header is read from the right, so the client-supplied entries an appending proxy leaves on the left never pick the bucket; behind more than one proxy tier, list the tiers in RateLimiterConfig.TrustedProxies (see rate-limit).

Limits are per-process by default. At N replicas the brute-force budget multiplies by N and a block on one replica doesn't hold on the others. Share the ledger through the database:

7 lines
shared := auth.NewSQLRateLimitStore(db, "auth_rate_limits") // creates its tables itself

auth.AuthConfig{
    LoginRateLimit:           &auth.RateLimiterConfig{Store: shared},
    LoginRateLimitPerAccount: &auth.RateLimiterConfig{Store: shared},
}
auth.TwoFAConfig{ RateLimit: &auth.RateLimiterConfig{Store: shared} }

One store instance can back every limiter: keys are namespaced by RateLimiterConfig.Scope, which each built-in limiter defaults to a distinct value (login_ip, login_account, register, twofa, …). A store error fails closed (denies with a short Retry-After): an attacker must never lift the limit by degrading its backend. See Horizontal scaling.

Under a concurrent burst the shared store also fails closed. Each attempt is recorded before it is counted, so no burst, on any number of replicas, admits more than MaxAttempts. The cost runs the other way: when a burst's inserts all land before its counts, every caller in it is refused, the legitimate one included, and the key is blocked. For a brute-force limiter that is the right side to err on. For a limiter whose budget a stranger can spend on someone else's behalf, it hands the stranger a way to lock the owner out, which is why the per-address mail limit (AddressRateLimit) keeps its default in memory. Put such a limiter on the shared store only if a fleet-wide bound matters more than the owner always getting through.

Security audit trail

Login success/failure, the 2FA lifecycle, password resets, OAuth links, and magic-link issuance are security-sensitive auth events emitted to an AuditSink on AuthConfig and land in the same audit_log table as the CRUD hooks. One line of wiring:

2 lines
sink, _ := auth.NewSQLAuditSink(db, "")
mgr := auth.New(auth.AuthConfig{ AuditSink: sink, … })

Events use a closed vocabulary (e.g. login.succeeded, 2fa.enrolled, password.reset_requested) and never carry credentials; see the audit log page for the full taxonomy and the redaction posture. A nil sink disables auditing entirely.

The identity mutations are covered too: a successful GET /auth/verify-email emits email.verified, an unlink emits oauth.unlinked (Meta: provider, mirroring oauth.linked), and AuthManager.SetUserRoles emits roles.updated (Meta: the new roles) — so a role change driven from a script or future surface leaves the same trail the admin back-office writes.

EntityUserStore.FindByID, EntityUserStore.UpdateRoles and AuthManager.SetUserRoles treat a user ID containing NUL or malformed UTF-8 as auth.ErrUserNotFound before querying or mutating storage. A refused role update emits no success audit. Valid IDs keep their exact bytes, including any leading or trailing whitespace.

The 2FA flow

5 lines
POST /auth/login            → 200, Set-Cookie session (PendingTwoFactor=true)
GET  /auth/me               → 403 "two-factor verification required"
POST /auth/2fa/challenge    → 200, Set-Cookie: a fresh session with TwoFactorVerified
                              (the pending one is deleted)
GET  /auth/me               → 200

The challenge replaces the pending session rather than marking it in place: a pending token that existed before the code was typed (one an attacker who knows the password minted and planted in the browser) never becomes a verified session. Clients that carry the session cookie by hand must take the new value from the challenge's Set-Cookie.

The login response still returns 200, so clients can't tell whether 2FA is required by the status code alone. A pending JSON login carries "two_factor_required": true and omits the JWT token field (a stateless JWT issued before the challenge would bypass the second factor on every JWT-authenticated route). Clients either read the flag or notice a follow-up endpoint returning 403, then drive the user through /auth/2fa/challenge with the TOTP code or a backup code.

If the session store cannot record the pending state, whether because it doesn't implement SessionPendingMarker, the mark call fails, or the 2FA state lookup itself errors, login fails closed with a 500 and the just-minted session is destroyed. A degraded 2FA backend never means "2FA is off".

TwoFAPlugin.RequireTwoFA() returns middleware for application routes that require a session-backed principal. It enforces step-up when that user has enrolled in 2FA; an unenrolled user still needs the principal and session evidence described here.

For a call marked by App.InProcessHandler(), RequireTwoFA requires a non-nil server-set context user and the 2FA assurance SessionMiddleware recorded for that same user on the outer request. The assurance is bound to the issuing AuthManager and the user. With custom stores, it also binds to the tenant under which the session was resolved by default. Only concrete built-in global stores or the explicit AuthConfig.GlobalSessionLookup opt-out omit this tenant binding. No assurance, a missing user, a different user or manager, or a mismatched tenant for tenant-partitioned lookup returns 401 with the no-session reason. It looks up current enrollment: an unenrolled user passes; an enrolled user needs verified assurance or gets 403. The assurance records whether any of that user's live, non-pending cookie sessions on the outer request completed 2FA. It contains no session token or session object. This snapshot does not re-check expiry or revocation during Local dispatch.

For an unmarked network request, install it behind SessionMiddleware or RequireAuth. It ignores context assurance and judges the request principal, the user GetCurrentUser(ctx) returns, and needs a live session cookie of that same user: 401 without a principal or without such a cookie, 403 when the user is enrolled and none of their session cookies on the request has cleared the challenge. A session of any other user proves nothing, so two cookies, or a JWT next to someone else's cookie, cannot borrow another session's step-up. A JWT or API token carries no step-up state of its own and passes only next to a stepped-up session cookie of the same user. The challenge records its result through SessionTwoFAMarker, which both built-in session stores implement. A custom session store without it cannot mark a session verified, so RequireTwoFA refuses an enrolled user on every request.

The 2FA endpoints apply the same binding: when an outer middleware put a principal in the context, verify, challenge, disable and backup-codes act only on a session cookie of that user. Every same-name cookie is considered, not only the first. disable also deletes the user's sessions still waiting on a challenge (SessionPendingPurger, implemented by both built-in session stores), so a pending login cannot complete later against a newly enrolled factor.

backup-codes regenerates against the row as it stands after the codes are hashed: a challenge, disable or re-enroll landing in between is kept, never written over. A row that was disabled, deleted or re-keyed in the meantime answers 409, as does a swap that keeps losing to other writes. It uses the store's TwoFAStateSwapper, which both built-in stores implement.

Every login path mints through MintSession

AuthManager.MintSession(ctx, userID, ttl) creates the session and applies the pending-2FA mark. Every built-in login path, whether password, magic link, OAuth callback, or passkey, goes through it, and a host-added plugin that mints its own sessions must too. Calling SessionStore().Create directly produces a session the 2FA enforcement never learns about: PendingTwoFactor stays false, so the session reads as fully authenticated and can even disable the factor it skipped.

It returns (session, pending, error). A failure to apply the pending mark is wrapped in auth.ErrTwoFactorEnforcement, the session is destroyed, and the caller must reject the login.

Step-up is checked positively

The 2FA self-service endpoints (enroll, verify, disable, backup-codes) require Session.TwoFactorVerified when the user has an enabled factor, not merely the absence of PendingTwoFactor. A session minted before the user enrolled carries PendingTwoFactor=false forever, so a negative-only test would leave it able to disable a factor it never proved. /auth/2fa/verify marks the enrolling session verified, so enrolment does not lock you out of your own settings.

POST /auth/send-verification applies the same pending check as those four.

Passkeys

PasskeyPlugin adds passkey sign-in. A passkey is a WebAuthn credential held by the device or password manager: the server stores a public key, and the browser proves possession by signing a one-time challenge. Nothing secret lives in the database.

16 lines
passkeys, err := auth.NewSQLPasskeyStore(db) // table "auth_passkeys"
if err != nil {
	log.Fatal(err)
}
challenges, err := auth.NewSQLMagicLinkTokenStore(db)
if err != nil {
	log.Fatal(err)
}
mgr.Use(auth.NewPasskeyPlugin(auth.PasskeyConfig{
	RPID:           "example.com",
	Origins:        []string{"https://example.com"},
	Store:          passkeys,
	ChallengeStore: challenges,
	OnSuccessURL:   "/app",
	TwoFactorURL:   "/login/2fa", // required when a 2FA plugin is registered
}))

RPID is the domain the passkeys belong to. Every origin in Origins must be that host or a subdomain of it, over https (or http://localhost in development), written the way browsers report it: no path, and no default port (https://example.com, not https://example.com:443). Init refuses anything else. A passkey registered for example.com works on app.example.com when both origins are listed. Outside DevMode and AllowInMemoryStores, Init also refuses to start without both stores, since a restart would forget every passkey and replicas could not share challenges. When a 2FA plugin is registered, Init needs TwoFactorURL too: the page where a pending sign-in posts its code to /auth/2fa/challenge.

Other knobs: RPName (shown by the browser, default RPID), ChallengeTTL (default 5 minutes), MaxPerUser (default 20) and RateLimit (default 30 requests a minute per client, then a 5-minute block).

MaxPerUser holds when registrations race: the plugin passes it to PasskeyStore.AddPasskey(ctx, cred, maxPerUser), which counts the user's passkeys and inserts as one step and answers auth.ErrPasskeyLimit past it. The SQL store does both in one transaction, behind an advisory lock on the user on Postgres; a custom store must make the pair atomic the same way.

Routes

RouteWhoWhat
POST /auth/passkey/login/optionsanyoneA sign-in challenge. No account list: the browser offers whatever passkeys it holds for the RP ID.
POST /auth/passkey/loginanyoneVerifies the signed challenge, mints the session cookie, answers {"redirect": "<OnSuccessURL>"}. When the account has 2FA it answers {"redirect": "<TwoFactorURL>", "two_factor_required": true} instead.
POST /auth/passkey/register/optionssigned inA registration challenge bound to the session's account.
POST /auth/passkey/registersigned inStores the new passkey, unless the account already holds MaxPerUser (409). Body: name (optional, at most 64 printable characters), id, clientDataJSON, attestationObject, all binary fields unpadded base64url.
GET /auth/passkey/listsigned inThe account's passkeys: id, name, createdAt, lastUsedAt, backedUp.
POST /auth/passkey/deletesigned inRemoves one of the account's own passkeys by id.

Every POST takes Content-Type: application/json, the options calls included, so a cross-site page cannot reach one without a CORS preflight. RateLimit covers all six routes.

"Signed in" means a full session. A session waiting on its second factor gets 403, and so does a password-only session of an account with 2FA turned on: adding a passkey adds a way in, so it takes the same proof as changing 2FA.

Buttons

The battery ships the browser half as a runtime module that loads when a marked button is on the page. Mark a ui.Button with the attribute helpers:

5 lines
ui.Button(ui.ButtonConfig{
	Label:      "Sign in with a passkey",
	Variant:    ui.ButtonSecondary,
	ExtraAttrs: auth.PasskeyLoginAttrs("/auth"),
})
HelperThe button
PasskeyLoginAttrs(base)Signs in, then navigates where the server says.
PasskeyRegisterAttrs(base)Adds a passkey to the signed-in account. Inside a form, a text field carrying PasskeyNameAttrs() names it.
PasskeyDeleteAttrs(base, id)Removes passkey id (a PasskeyCredential.ID).

The buttons render hidden. The module reveals them when the browser supports WebAuthn, so a page without script never shows a button that cannot work; keep the password form beside the passkey button. After an add or a remove the module re-renders the current page, so a list the server drew from PasskeyStore.ListPasskeys shows the change. Failures show as a toast. A prompt the user cancelled shows nothing. The base path must be a same-origin path such as /auth; any other value leaves the button inert.

Behind auth.CSRF or WithBFFPosture, render the page's token as <meta name="csrf-token" content="…"> from auth.CSRFToken(r). The module sends it as X-CSRF-Token on every request, the same way the runtime's actions do. Agent-authored node IR can't place these buttons: noderender.RenderNode drops every registered behaviour marker.

What a ceremony checks

Registration asks for a discoverable credential with user verification and requests no attestation, so the server learns nothing about the device and verifies none. The plugin accepts ES256, EdDSA and RS256 keys (RSA keys of 2048 to 8192 bits with an odd modulus: verification cost grows with the square of the key size, so a larger key is refused at registration and at sign-in).

Both ceremonies check the client data's type, origin and challenge, and refuse a cross-origin call. They check that the authenticator data names the RP ID and that the user was present and verified. Each challenge is spent on its first use, before the signature is checked, whether that use succeeds or not. A registration challenge belongs to the account that asked for it. At sign-in the passkey's stored account must match the user handle the authenticator returns.

Once the request parses, every failed check answers the same 401 passkey sign-in failed, and the reason goes to the audit trail as passkey.refused with a fixed reason code (the request's own text goes only to the log). A malformed body is a 400 that names the field. If two sign-ins with the same passkey race, the loser gets 409 and can retry. If the 2FA state cannot be read, the sign-in fails closed with a 500 and a login.failed event (reason=twofa_failclosed), the same as a password login. A user or passkey store that errors answers 500 on every route; only a missing account reads as signed out.

Backup eligibility is fixed for a credential's life. A sign-in whose authenticator data flips the BE bit from what registration recorded is refused.

The signature counter catches cloned keys. When either the stored or the presented counter is non-zero and the presented one has not moved past the stored one, the sign-in fails with passkey.clone_suspected. Synced passkeys always report zero, so they never trip it.

Accounts and data

A passkey sign-in mints through MintSession, so an account with 2FA lands on a pending session and the browser goes to TwoFactorURL to finish at /auth/2fa/challenge, like a password login. A completed password reset removes every passkey on the account along with its sessions and API tokens: a passkey added from a stolen session would otherwise sign in again. The passkeys go before the sessions, and a passkey sign-in re-reads its key after minting and drops the session if the key is gone, so a sign-in a reset overtakes ends with no session. A registration that a reset, change or claim overtakes removes the key it stored and answers 401. A password change keeps them, as it keeps API tokens. Claiming an unverified account (see Proving the mailbox claims an unverified account) removes its passkeys, since the squatter may have added one, and the account.claimed event counts them in passkeys_removed. App.EraseUserData deletes the user's passkeys and pending registration challenges, and App.ExportData includes the auth_passkeys table.

Account linking

5 lines
GET    /auth/accounts                   → list of linked OAuth providers + profile
DELETE /auth/unlink/{provider}          → remove a link
GET    /auth/oauth/{provider}           → sign in with a provider (login flow)
GET    /auth/oauth/{provider}/link      → link a provider to the CURRENT user (authenticated)
GET    /auth/oauth/{provider}/callback  → callback, binds (provider, providerID)

Unlink refuses (409) when the requested unlink would leave the user with no remaining login method. The check considers both linked OAuth accounts and whether the user has set a real password.

Unlink also drops the stored provider tokens: when the OAuth2 plugin has a TokenStore (OAuth2Config.TokenStore), a successful DELETE /auth/unlink/{provider} deletes the (user, provider) row, so RefreshOAuthToken / ValidOAuthToken stop serving a provider the user revoked — the stored refresh token is a password-equivalent for the provider API and must not outlive the link. If the row cannot be deleted, the response is 500 ("unlink succeeded but the stored provider token could not be revoked") rather than a silent half-revocation. The link is already gone at that point, so retry the same request: an unlink of a provider with no link still drops its stored tokens before answering 404.

Linking a provider to a logged-in account (the recovery path)

When an OAuth login's verified email matches an existing password account, the login callback refuses with 409: a bare OAuth round-trip must not take over a local credential (see the decision table below). The supported way to add that provider is the authenticated link flow:

  1. While logged in, the user hits GET /auth/oauth/{provider}/link. This requires a valid, fully-authenticated session (a pending-2FA session is rejected) and encodes the current user's id into the HMAC-signed OAuth state, then redirects to the provider.
  2. The provider returns to the normal callback. Because the signed state carries a user id, the callback runs the link branch instead of the login decision table: it re-checks that the session still belongs to that same user, refuses (409) if the provider identity is already bound to a different account, and otherwise links (provider, provider_id) to the proven user. It then reads the binding back: a concurrent link of the same identity to another account that won the race also answers 409 (auth.provider_linked_elsewhere, an oauth.refused event with reason=provider_already_linked), never a success with an oauth.linked event. A successful link stores the provider's tokens in OAuth2Config.TokenStore, as a login does.

The provider's redirect back is a cross-site navigation, so the browser withholds the SameSite=Strict session cookie. When a cross-site callback carries a valid, unspent link state and its state cookie but no session, it answers a small page that reloads the same callback from this site: a Refresh: 0;url=… header plus a "Continue" link (phrases auth.oauth_hop_title and auth.oauth_hop_continue), drawn by AuthConfig.Pages when set. That second request carries the session cookie and completes the link.

The user has proven ownership of both the account (session) and the provider (the OAuth round-trip), so this is the ONLY path that may link a provider whose email matches a password account. A forged or altered state fails the HMAC; a mismatched session is rejected (403); no session is rejected (401).

The link callback checks again once the link is stored. An account claim that overtook it has already removed every link and is deleting the sessions; a reset or password change has moved the hash. When the callback's session is gone or the password hash moved since it started, it removes the link it just wrote and answers 401 auth.invalid_session. The removal runs detached from the request, so a client that hangs up cannot keep the link; if it fails, or the store has no AccountUnlinker, the callback answers 500 auth.link_failed.

Proving the mailbox claims an unverified account

Anyone can register an address they do not own, or sign in through an IdP that does not verify email, before the address's owner shows up. Each account therefore carries email_verified, and only these flows set it:

  • a magic link redeemed for an account it creates;
  • a magic link or a completed password reset on an unverified account (the claim below);
  • GET /auth/verify-email opened in the account's own session;
  • an OAuth callback whose IdP asserts email_verified for the account's address (step 1 or step 3 of the callback resolution policy).

When a magic link or a reset proves the mailbox of an account that is still unverified, the account is claimed before the owner gets a session. In order:

  1. every session is deleted, so a request attaching a credential under one (an OAuth link callback, a token or passkey registration) finds its session gone when it checks again after writing;
  2. every OAuth link is removed, and the provider tokens stored for the account in OAuth2Config.TokenStore are deleted: one row per provider it unlinked and per provider the OAuth2 plugin registers, so a retry also reaches a row an earlier, failed attempt left behind. A row that cannot be deleted fails the claim after the remaining steps run, before the account is marked verified;
  3. the password is cleared (magic link only; a reset keeps the new one);
  4. every passkey is removed;
  5. every API token issued through TokensPlugin is revoked;
  6. every session is deleted again, since a sign-in through a credential steps 2-5 removed may have minted one after step 1;
  7. the account is marked verified and account.claimed is emitted.

The account is marked verified last, so a failure anywhere leaves it unverified, the request answers 500, and the next proof runs the whole claim again. A store missing an extension the claim needs is an error, never a skipped step. An enrolled second factor survives the claim: the mailbox is one factor, and stripping 2FA here would let a mailbox alone remove it from every account that predates email_verified.

An OAuth sign-in already under way when the claim lands does not keep the account. Its profile refresh is an UPDATE, so it cannot put back a link the claim removed, and once its session is stored it resolves the link again. A link that is gone or names another account drops the session: the callback answers 401 auth.oauth_link_removed and emits oauth.refused with reason=link_removed. The callback stores provider tokens only after that check passes, so a refused sign-in leaves none behind.

A verified account is never claimed: a magic link signs its owner in and leaves the password and other sessions alone.

GET {basePath}/magic-link/verify?token=… renders a confirmation screen ("Continue to sign in as x@y?") and redeems nothing. A same-origin POST from that page, carrying the token in a token field, is what actually signs the user in.

The link IS the credential, so without a confirmation step an attacker could request a link for their own account and get a victim to open it: the victim's browser would silently hold a session for the attacker's account, and everything typed next would land there. The GET is invisible to the cross-site form rejection, and the usual fixes do not fit: a browser-binding cookie breaks cross-device links (request on a laptop, open on a phone) and refusing Sec-Fetch-Site: cross-site breaks every webmail client. A confirmation keeps both working.

The confirming POST answers 302 to OnSuccessURL. For an account with a second factor the session it mints is pending, and every route but /2fa/challenge refuses it, so the redirect goes to MagicLinkConfig.TwoFactorURL instead: the app's page that posts the code. Init refuses to start when a 2FA plugin is registered (before or after the magic-link plugin) and TwoFactorURL is empty, as the passkey plugin does. The magiclink.consumed event carries two_factor=pending on that path. When the 2FA state cannot be read, no session is minted: the POST answers 500 and emits login.failed with reason=twofa_failclosed, via=magic_link.

The magiclink.requested event names the account (UserID) when the address has one, so an erasure, which scrubs the audit trail by actor id, reaches it. The response is the same either way.

Set AuthConfig.Pages to draw this screen, and the same-site hop below, with the design system. authui.Pages (battery/auth/authui) renders both as a ui.AuthCard through the app's UIHost, so they carry its layout, theme and runtime:

7 lines
import "github.com/DonaldMurillo/gofastr/battery/auth/authui"

host := uihost.New(site)
mgr := auth.New(auth.AuthConfig{
    // ...
    Pages: authui.Pages(host),
})

To draw them some other way, implement auth.Pages. Its two methods get the response writer, the request and the page data; the battery has already set Cache-Control: no-store and Referrer-Policy: no-referrer, because both URLs carry a credential:

31 lines
type myPages struct{ host *uihost.UIHost }

func (p myPages) MagicLinkConfirm(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) {
    p.host.RenderScreen(w, r, confirmScreen{d: d}, uihost.ScreenResponse{})
}

func (p myPages) Hop(w http.ResponseWriter, r *http.Request, d auth.HopPageData) {
    p.host.RenderScreen(w, r, hopScreen{d: d}, uihost.ScreenResponse{})
}

func (s confirmScreen) RenderCtx(ctx context.Context) render.HTML {
    return ui.AuthCard(ui.AuthCardConfig{
        Title: s.d.Words.Title,
        Body: ui.Form(ui.FormConfig{
            Action:      s.d.Action,
            SubmitLabel: s.d.Words.Submit,
        },
            // Words.Ask is escaped already, the address included. Any
            // other dynamic value (d.Email, d.Token) goes through an
            // escaping builder, never into render.HTML: a magic-link
            // request accepts any address, so d.Email is
            // caller-supplied.
            html.Paragraph(html.TextConfig{}, render.HTML(s.d.Words.Ask)),
            html.Paragraph(html.TextConfig{}, render.Text(s.d.Words.Warning)),
            // token is the credential, posted back to d.Action.
            html.Input(html.InputConfig{Type: "hidden", Name: "token", Value: s.d.Token}),
            // CSRFField is required: without it the submit 403s
            // under auth.CSRF(), which WithBFFPosture mounts app-wide.
            render.HTML(s.d.CSRFField)),
    })
}

Render d.CSRFField inside the form. Omit it and the submit 403s under auth.CSRF(), which WithBFFPosture mounts app-wide, so passwordless sign-in stops at the confirmation step. (authui passes the request context to ui.Form, which stamps the same field itself, and adds d.CSRFField only when the context carries no token.) d.Words holds the screen's words in the caller's locale: the phrases are auth.confirm_title, auth.confirm_as, auth.confirm_this_account, auth.confirm_warning and auth.confirm_submit.

Leave Pages nil and you get plain but correct documents with no CSS and no script, in the caller's language when the app has a translator. The battery does not import framework/ui itself: API-only apps use auth too and should not pay for the design system.

The emails follow the same rule. BodyTemplate on MagicLinkConfig, EmailVerificationConfig and PasswordResetConfig is func(ctx context.Context, url string) string, and EmailSender.Send takes a ctx too. Both carry the requester's locale (the reset worker gets a detached copy that keeps it), so a body or subject rendered from your phrases goes out in the language the person asked in:

7 lines
var resetBody = i18n.Define("myapp.reset_body", "Reset your password: {{url}}")

auth.PasswordResetConfig{
    BodyTemplate: func(ctx context.Context, url string) string {
        return resetBody.Text(ctx, map[string]any{"url": url})
    },
}

The screen names the account when the token store implements MagicLinkTokenPeeker (both shipped stores do). Naming it is the point: it is how someone recognises the link was not meant for them.

The callback is bound to the browser that started the flow

Starting an OAuth flow (GET /auth/oauth/{provider} or .../{provider}/link) plants a short-lived HttpOnly, SameSite=Lax cookie holding the same signed state token that goes into the redirect URL. The callback requires the two to match and clears the cookie either way.

With SessionSecure on (the production default) the cookie is __Host-gofastr_oauth_state, and the callback reads that name alone. A sibling subdomain can set a cookie for the parent domain, so a plain name let evil.example.com plant its own state for the victim's callback and sign the victim into the attacker's account. A browser accepts a __Host- cookie only from the host itself, Secure, with Path=/. DevMode over plain HTTP uses gofastr_oauth_state, since a browser refuses a __Host- cookie without Secure.

Without it the state token, unforgeable and single-use though it is, binds the callback to nothing about the caller: an attacker could start a flow in their own browser, capture the resulting callback URL, and get a victim to load it, leaving the victim's browser holding a session for the attacker's account. The callback is a GET, so the form-level cross-site rejection never sees it.

SameSite=Lax is the reason this does not break the flow: a Lax cookie still rides the provider's top-level redirect back. A callback URL replayed into any other browser carries no cookie and is refused with 400 oauth flow was not started in this browser.

Single use is enforced by an in-process map of spent state nonces, kept until each state's expiry. A callback with no code is refused before its nonce is recorded. The map holds at most 65,536 nonces; when it is full, the earliest-expiring ones are dropped to make room. It does not refuse callbacks at the cap: anyone can start a flow and keep its cookie, so a cap that refused would let two requests per entry lock every user out of OAuth sign-in for ten minutes. Dropping an entry leaves replay closed in practice, because a replay also needs the state cookie, which the callback clears, and the IdP accepts a code once.

Provider keys

The key a provider is registered under (OAuth2Config.Providers or RegisterProvider) is its {provider} route segment and a field of the state token. It must be 1 to 64 ASCII letters, digits, - or _; NewOAuth2Plugin and RegisterProvider panic on any other key, as they do on a short StateSecret. A key holding a . used to register cleanly and then fail every login with "invalid or expired state", since the state token separates its fields with dots.

OAuth identity linking

OAuth login MUST bind the returned identity to (provider, provider_id) via a durable link store. (provider, provider_id) is the only assertion an IdP makes that survives an email change: email is mutable, names collide, and an unverified email is attacker-controllable. The link store is the serialization point that closes the account-takeover hole where an IdP emitting an unverified email signs in as an existing local account.

EntityUserStore implements OAuthLinker and creates its link table on EnsureSchema, named <user_table>_oauth_links by convention, with a composite primary key on (provider, provider_id) and a user_id index that powers ListAccounts / UnlinkOAuth. The table is created automatically; hosts never hand-roll the DDL. A user may link more than one provider (Google + GitHub + a corporate OIDC), so links live in their own table rather than as columns on the users table.

Fail-closed Init

OAuth2Plugin.Init refuses to boot when the configured UserStore does not implement OAuthLinker and neither DevMode nor AllowInMemoryStores is set:

5 lines
auth: OAuth login requires a durable (provider, provider_id) → user_id
link store — the configured UserStore does not implement OAuthLinker, so
an IdP emitting an unverified email could sign in as an existing account.
Use auth.NewEntityUserStore(...) (now a linker) or set
AuthConfig.AllowInMemoryStores: true for local dev

The legacy email-only fallback is gone. DevMode: true and AllowInMemoryStores: true keep the no-linker path reachable so the rest of the OAuth plumbing (redirect, state token, callback errors) stays unit-testable; the path logs a WARN, and resolveOAuthUser itself returns errOAuthNoLinker at request time rather than silently downgrading.

Callback resolution policy

resolveOAuthUser runs this decision table on every callback:

  1. Existing link. FindByOAuth(provider, providerID) hit → log in as the linked user. Profile refresh via RefreshOAuthProfile is best-effort and does not block login. When the IdP asserts email_verified for exactly the account's address, the account is marked verified (best-effort).
  2. Verified-email match. No link, but the IdP asserts the email is verified (OAuth2UserInfo.EmailVerified == true) and FindByEmail returns an existing account. The branch depends on whether the existing account has a password: - Password account → refuse with 409 errOAuthEmailCollision. The user must log in with their password and link the provider from /auth/accounts: a verified email alone must not bind to a credential the IdP didn't issue. - Passwordless account, address unproven → refuse with 409 errOAuthEmailCollision. The account may have been created through an IdP that does not verify email, by someone who does not own the address. The owner claims it with a magic link or a password reset (see Proving the mailbox claims an unverified account), which removes the other link, and then signs in here. A store without EmailVerifiedChecker always lands here. - Passwordless account, address proven → AUTO-LINK and log in. The verified email re-binds the same identity.
  3. Otherwise. No link, no verified-email match (this includes an UNVERIFIED email matching an existing account), or no email match at all → create a new passwordless user and link the (provider, provider_id). The new user is marked verified only when the IdP asserted email_verified. An unverified address is stored as given but the account stays unproven, so it cannot absorb a verified login and the mailbox owner can claim it. A concurrent create that wins the link PK is authoritative; the just-created user is left as an orphan (best-effort ignore; the email-unique constraint means a future callback re-resolves cleanly).

An empty email is no email. When the provider shares no address (a GitHub account with no verified email, an IdP that omits the claim) and no link names the identity, steps 2 and 3 do not run: the callback answers 400 auth.oauth_no_email and emits oauth.refused with reason=no_email. Matching by "" merged every email-less identity into the first one's account. An identity that is already linked signs in through step 1 with or without an email. A provider address longer than 254 bytes is refused with 400 auth.invalid_email, the bound the form paths apply.

The 409 refusals in step 2 emit oauth.refused with reason=link_conflict and UserID set to the local account the address belongs to, so App.EraseUserData removes the row with that account's other audit history.

An unverified email NEVER binds to an existing account. It falls through to step 3 as if the email didn't match: the core takeover regression. If the email happens to collide with an existing account, the unique-email constraint then blocks the fresh create and the callback fails closed.

email_verified enforcement

OAuth2UserInfo.EmailVerified is the signal step 2 uses. It is set strictly:

  • OIDC: parsed from the id_token's email_verified claim (or the configured OIDCClaimsMapping.EmailVerifiedClaim). Accepts a JSON bool or the string "true" (some IdPs emit the value as a string). When the id_token lacks the claim, the userinfo response's flag is used only if its email is exactly the one kept; a flag about another address does not verify this one. Defaults to false when absent: a missing assertion is never a verified email. The flag describes the standard email claim only, so it counts only when that claim (in the same id_token or userinfo response) is exactly the address kept. With OIDCClaimsMapping.EmailClaim mapped to another claim, the address is verified only when it equals the verified email.
  • Google: from the email_verified (modern) or verified_email (legacy) userinfo field.
  • GitHub: true only when the email came back from /user/emails?verified=true. GitHub's /user endpoint surfaces the public email but does not assert verification; the conservative default is false.

A provider that cannot assert verification always yields EmailVerified == false, which means email-only matching is off for that callback.

Migration (BREAKING)

Before this change, the callback fell back to email-only matching when the UserStore did not implement OAuthLinker, and OIDC never checked email_verified. Both paths silently let an IdP emitting an unverified email sign in as an existing local account.

After upgrade:

  • Existing OAuth links are unchanged. Users who logged in via OAuth before the upgrade already have a (provider, provider_id) row in the link table (or get one on their first post-upgrade callback, via the verified-email + passwordless auto-link branch).
  • Passwordless accounts auto-relink once their address is proven. An account whose only credential is a prior OAuth login has password_set = false. A verified-email callback re-binds it once the account reads email_verified, which a login through an already linked provider asserting that address records. Until then the callback answers 409.
  • Password accounts must link a provider from settings. The first OAuth login for an email that belongs to a password account is refused with 409 by design: the user must log in with their password and link the provider via /auth/oauth/{provider} while authenticated, then /auth/accounts reflects the bind. This blocks an attacker controlling a verified-but-not-theirs email from taking over a password account.
  • Hosts on a non-EntityUserStore backend must implement OAuthLinker (and ideally OAuthEnrichedLinker, AccountLister, AccountUnlinker, PasswordChecker) or set AuthConfig.AllowInMemoryStores: true to acknowledge the risk. The Init failure message names both options.

OAuth token store + refresh

A provider access token is short-lived (Google's is ~1h). Without a durable store, the provider's refresh token is discarded at login and any call made on the user's behalf fails once the access token expires, with no recovery. The OAuthTokenStore makes that recoverable. It is opt-in: OAuth login behaves exactly as before when no store is configured. A stored row is dropped automatically when the user unlinks the provider (see Account linking).

OAuth2Config.StateSecret signs the HMAC state token. It must be at least 16 bytes — NewOAuth2Plugin panics on a shorter non-empty value, because state tokens travel in redirect URLs and are brute-forceable offline under a short key. Empty keeps the mint-a-random-per-process-key behaviour (single-instance deployments).

9 lines
tokStore, _ := auth.NewSQLOAuthTokenStore(db, auth.SQLOAuthTokenStoreConfig{
    EncryptionKey: []byte(os.Getenv("OAUTH_TOKEN_KEY")), // seals tokens at rest
})

oauth := auth.NewOAuth2Plugin(auth.OAuth2Config{
    Providers:   map[string]auth.OAuth2Provider{"google": google},
    StateSecret: os.Getenv("OAUTH_STATE_SECRET"),
    TokenStore:  tokStore, // persist {user, provider, access, refresh, expiry} at login
})

With a store wired in, the callback handler persists the access and refresh tokens (upsert per (user_id, provider)). Both token columns are sealed with AES-GCM before they touch the database; a raw table dump does not surface live secrets. EncryptionKey is required and non-empty: stored refresh tokens are password-equivalent, so NewSQLOAuthTokenStore fails closed rather than sealing them with a default key. Source it from a secret manager, not source code.

Pass the authenticated user's id. RefreshOAuthToken / ValidOAuthToken take a userID; it must be the resolved session principal, never a request-supplied value, or it is an IDOR onto another user's tokens.

Making a call on the user's behalf:

6 lines
// Returns a currently-valid access token, refreshing transparently when
// the stored one is expired or within ~60s of expiry.
accessToken, err := auth.ValidOAuthToken(ctx, tokStore, google, userID)

// Or force a refresh and get the full updated record:
rec, err := auth.RefreshOAuthToken(ctx, tokStore, google, userID)

The callback stores each row under the provider's registry key, the {provider} of /auth/oauth/{provider}. The package functions above read the row under provider.Name(), which is the same string only when the provider is registered under its own name. An OIDCProvider registered as "keycloak" reports Name() "oidc" unless ProviderName says otherwise, and the package functions then find no row (ErrOAuthTokenNotFound). The plugin's methods take the registry key and always match the callback:

2 lines
accessToken, err := oauth.ValidOAuthToken(ctx, "keycloak", userID)
rec, err := oauth.RefreshOAuthToken(ctx, "keycloak", userID)

They use the plugin's OAuth2Config.TokenStore and return an error for a key no provider is registered under.

A token whose provider stated no expires_in (GitHub's classic OAuth-app tokens, which never expire) is stored with a zero Expiry, and ValidOAuthToken returns it as it is. Before, its expiry was set to the moment it arrived, so every call forced a refresh that could not happen (no refresh token) and failed. A provider call refused with such a token means the grant was revoked: refresh, or have the user sign in again.

Refreshes of one (user, provider) run one at a time, within a process and across replicas. A refresh token spent twice fails under a provider that rotates them, and one that detects reuse (Auth0, Okta; RFC 9700 §4.14.2) revokes the whole grant, so the user has to reconnect.

  • Within a process, a caller that waited behind another's refresh gets that refresh's record instead of spending the token again.
  • Across replicas, SQLOAuthTokenStore leases the grant in its row (one conditional UPDATE) before the token is spent. A replica that finds another's lease never spends it: it waits up to 10 seconds, bounded by its context, for that refresh to land and returns the stored record. If nothing lands it fails closed with auth.ErrOAuthRefreshInProgress and the token stays unspent; retry shortly. A lease lapses after 30 seconds, so a replica that died mid-exchange frees the grant, and a failed exchange releases it at once.
  • A custom store opts in by implementing auth.OAuthTokenLeaser (LeaseRefresh, ReleaseRefresh); one that does not is serialized per process only.

The final write stays a compare-and-swap on the refresh token refreshed from, so a grant a login stored meanwhile is never overwritten. The lease column (refresh_lease_ms) is added to an existing table on first use.

Refresh is concrete per provider; there is no generic provider registry. GoogleProvider and GitHubProvider implement OAuthTokenRefresher (RefreshToken(ctx, refreshToken)), POSTing a refresh_token grant to the provider's token endpoint. Providers commonly omit the refresh token on a refresh grant (Google does), so the stored refresh token is retained. GoogleProvider.AuthURL now requests access_type=offline so Google actually issues a refresh token.

Every built-in provider fetch — token exchange, refresh, userinfo, /user/emails — refuses redirects and caps the response body at 1 MiB, the same egress posture as the OIDC provider: a compromised or misconfigured endpoint can neither re-point the credential-bearing request at another origin (a 307 re-sends the POST body verbatim) nor stream an unbounded body into the callback goroutine.

RefreshOAuthToken errors when no refresh token is stored; the user must re-authenticate. Security-sensitive code: route changes here through the auth audit gate before merge.

OIDC (any compliant IdP)

OIDCProvider adapts any OpenID Connect-compliant identity provider (Keycloak, Authentik, Authelia, Zitadel, Entra ID, Okta) to the same OAuth2Provider interface as the built-in Google/GitHub providers: one config block, discovered endpoints, JWKS-verified id_tokens.

15 lines
provider, err := auth.NewOIDCProvider(auth.OIDCConfig{
    Issuer:       "https://keycloak.example/realms/myrealm",
    ClientID:     "my-client",
    ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
    RedirectURL:  "https://app.example.com/auth/oauth/keycloak/callback",
    ProviderName: "keycloak", // Name() and OAuth2UserInfo.Provider
    // Scopes defaults to ["openid","email","profile"]; set to override.
    // JWKSCacheTTL defaults to 1h.
    // TokenAuthMethod: "" follows discovery (see below).
})

oauth := auth.NewOAuth2Plugin(auth.OAuth2Config{
    Providers:   map[string]auth.OAuth2Provider{"keycloak": provider},
    StateSecret: os.Getenv("OAUTH_STATE_SECRET"),
})

Discovery (<issuer>/.well-known/openid-configuration) runs lazily on first use and is cached for the life of the process; a restart picks up IdP endpoint moves. The document's issuer MUST match the configured Issuer exactly (OIDC §4.3, issuer-spoofing guard). Issuer must be an https:// URL; http:// is accepted only for localhost/127.0.0.1 (local IdPs and tests).

A JWKS RSA key over 8192 bits is refused, like one under 2048: a huge modulus would make every sign-in burn CPU on verification.

Discovery and JWKS fetches run one at a time and never under a lock other logins wait on. A login waiting for another login's discovery fetch gives up at its own deadline. Once a key set is cached, a login whose set is past JWKSCacheTTL verifies against the cached set while a single refresh runs, and when that refresh fails the next attempt waits a minute: an IdP outage no longer turns every login into a JWKS fetch that runs into the HTTP timeout. A token signed by an unknown kid still triggers its own refetch, at most once per kid per five minutes and not during that minute.

Every endpoint the discovery document names (token_endpoint, jwks_uri, userinfo_endpoint, authorization_endpoint) is transport-validated when the document is accepted: it must be https:// — or, under a literal-loopback http:// issuer, plain http:// on a literal-loopback host only — and must not carry userinfo. Distinct hosts are allowed (OIDC legitimately serves JWKS or userinfo from another origin); a scheme or cleartext-host downgrade is not. None of the provider's fetches follow redirects: these endpoints are pinned by the issuer's document, a redirect answer is not a shape a conformant IdP produces on them, and following one would re-send the token exchange's POST body — client_secret and code — verbatim to the redirect target (Go's client preserves method and body on 307/308). Mirrors the redirects-off posture of A2A push delivery and webhook egress.

The code exchange authenticates the client one of two ways. auth.OIDCAuthClientSecretPost sends client_id and client_secret in the form body; auth.OIDCAuthClientSecretBasic sends them in an HTTP Basic Authorization header, each form-encoded first (RFC 6749 §2.3.1). With TokenAuthMethod empty, the discovery document's token_endpoint_auth_methods_supported decides: the body form when the list is absent or names client_secret_post, Basic when it names client_secret_basic and not post. A list naming neither fails the exchange before the secret is sent. Set TokenAuthMethod when a provider's document is wrong about what its endpoint accepts; any other value fails NewOIDCProvider.

What gets verified before ExchangeCode returns:

  • the id_token signature against the IdP's JWKS;
  • alg is pinned to RS256 or ES256; none, HS256, and case variants are rejected before any key lookup (alg-confusion defense);
  • the signing key's kty/crv matches the alg (no RSA-vs-EC confusion); RSA moduli below 2048 bits and off-curve EC points are rejected at JWKS parse time;
  • iss equals Issuer; aud contains ClientID; a present azp must equal ClientID (and a multi-audience token must carry one);
  • exp (60s skew), nbf (tokens not yet valid are rejected), and iat (tokens issued in the future are rejected);
  • a non-empty sub.

No nonce is sent on the authorize request: this is the confidential-client authorization-code flow: the code is single-use and exchanged server-to-server with the secret, and the plugin's HMAC state token already binds the callback. A nonce only matters for the implicit/hybrid flow.

PKCE (RFC 7636, S256) is sent by the OIDC, Google and GitHub providers. The client secret protects the code→token step, but not against authorization-code injection: an attacker holding a victim's code (a leaked redirect, a log, a referer) starts a flow of their own and swaps the code into its callback. Their state and cookie check out and the server redeems the code with its own secret, signing them in as the victim. RFC 9700 recommends PKCE for confidential clients for this reason. The verifier is the HMAC of the flow's signed state under the state key: secret (the key never leaves the server), different per flow, and recomputed at the callback, so nothing is stored. A stolen code fails the IdP's verifier check in any flow but its own.

OIDCConfig.DisablePKCE, GoogleProvider.DisablePKCE and GitHubProvider.DisablePKCE turn it off for an IdP that refuses the parameters. A custom provider opts in by implementing auth.OAuth2PKCEProvider (AuthURLWithChallenge, ExchangeCodeWithVerifier); one that does not keeps working without PKCE.

Claims mapping. OIDCClaimsMapping overrides which claim supplies each field (defaults sub, email, name, picture, email_verified) for IdPs that use preferred_username or upn:

1 lines
Claims: auth.OIDCClaimsMapping{EmailClaim: "upn", NameClaim: "preferred_username"},

A remapped EmailClaim does not inherit email_verified. That flag (OIDC Core §5.1) is about the standard email claim, so an address taken from upn or preferred_username counts as verified only when it is exactly the email the IdP verified. Otherwise anyone who could set their own preferred_username to someone else's address, while verifying their own email, would arrive as a verified owner of that address and be linked into its account. Under a remap most sign-ins therefore arrive unverified: they get a new account and never auto-link into an existing one.

email_verified is enforced. The callback reads the IdP's email_verified claim (JSON bool, or the string "true" for IdPs that emit it that way) and refuses to bind an UNVERIFIED email to an existing account; see OAuth identity linking. A missing claim defaults to false: a missing assertion is never a verified email.

If the mapped email is empty and the IdP exposes a userinfo_endpoint, it is fetched with the bearer token and only the missing fields are merged in; the userinfo sub MUST match the id_token sub (OIDC §5.3.2).

Separation from app JWTs. This is a distinct verifier from the HS256-only JWT verifier in token.go. Application-issued session JWTs stay HS256-only by design; only third-party id_tokens are validated here, against the IdP's published asymmetric keys. The two never share a code path.

Threat model assumptions

  • The application sits behind a TLS terminator that rewrites r.RemoteAddr to the real client IP. Client-supplied XFF is not trusted; if you need it, set TrustForwardedFor explicitly.
  • Cookies are scoped to a single origin. The __Host- prefix enforces this on the browser side. Cross-subdomain attacker? Blocked by the prefix.
  • The session table stores sha256(token), never the cookie value, so a read-only dump of it is not a pile of live sessions. A write-capable compromise of the session table is still game over: sessions are bearer tokens by design, and an attacker who can insert rows can mint sessions directly.
  • The EmailSender is reliable. Plugins that need email return 503 if no sender is configured and DevMode is off: they refuse to silently log live tokens to stdout in production.
  • The crypto/rand source is available. If it fails, the process panics (entropy starvation makes the rest of the system unsound).

Common mistakes

  • Wiring a custom token store for one plugin only. The magic-link, email-verification, and password-reset plugins each construct their own MemoryMagicLinkTokenStore. If you replace one with a Redis store, replace all three; they share the same shape but not the same instance. In production mode a plugin left on its memory store refuses to boot (a link emailed from one replica would be invalid on the next) unless AuthConfig.AllowInMemoryStores is set.
  • Forgetting DevMode over plain HTTP. Without it, browsers refuse to accept the production __Host-session cookie over an insecure connection, and the user appears never to log in. The symptom is "login returns 200 but /auth/me returns 401".
  • Leaving EmailSender nil in production. Magic-link and email verification fail closed with a 503 (magic link delivery not configured / email delivery not configured). Password reset is the exception: to preserve its anti-enumeration guarantee it still returns 200 and simply sends nothing, so the reset flow is silently non-functional until an EmailSender is wired. A failing (non-nil) sender now logs a server-side password-reset email send failed warning while keeping the 200; a nil sender is the quiet footgun to watch for. Don't set DevMode=true as a workaround; that logs live tokens.
  • Trusting X-Forwarded-For without a proxy. Per the docs above: default is off, and turning it on without a proxy you control defeats every per-IP rate limit.
  • Treating /auth/login success as "fully authenticated". A 2FA- enrolled user has a PendingTwoFactor session until they complete /auth/2fa/challenge. Don't read user PII from a session that's still pending.
  • Losing or leaking the 2FA encryption key. NewEntityTwoFAStore refuses an empty EntityTwoFAStoreConfig.EncryptionKey and seals every TOTP secret it writes with AES-GCM under it. Rows written as plaintext before the key existed still verify until they are rewritten only when they use unpadded uppercase base32. Lowercase-base32 seeds and other legacy encodings fail closed. For an offline migration, decode each seed using its original encoding, encode the same bytes as unpadded uppercase base32, then rewrite it through the configured store so it is sealed. If the original encoding is unknown, require re-enrollment rather than guessing. Keep the key in a secret manager, not beside the database: a DB leak plus the key is a full second-factor bypass. Rotate through PreviousEncryptionKeys.