lagodev ships two complementary authentication models and the credential-lifecycle plumbing around them:
- Stateless JWT (
auth) — bearer tokens for APIs and SPAs. - Stateful session guard (
auth/guard+session) — HttpOnly-cookie login for server-rendered apps, Laravel "web" guard style. - Personal access tokens (
auth/token) — Sanctum-style opaque, scoped API keys. - Social login (
auth/oauth) — OAuth2 / OIDC with mandatory PKCE. - Account flows (
auth/account) — password reset, email verification, signed URLs, and a login throttler.
Every package is framework-agnostic: it takes context.Context, depends on
nothing in web, and exposes a small Store/Provider seam so you can swap
the in-memory default for SQL or Redis without touching call sites. Secrets are
never stored in plaintext (bcrypt for passwords, SHA-256 for tokens), and the
secure choices are the defaults.
A JWT-protected API needs nothing more than a Manager:
import "github.com/devituz/lagodev/auth"
mgr, err := auth.New(auth.Config{
Secret: os.Getenv("APP_KEY"), // >= 32 bytes
Issuer: "myapp",
AccessTTL: 15 * time.Minute,
RefreshTTL: 30 * 24 * time.Hour,
})
if err != nil { log.Fatal(err) }
// On login: hash check, then issue a token pair.
if !mgr.VerifyPassword(user.PasswordHash, form.Password) {
return c.Unauthorized()
}
access, exp, _ := mgr.IssueAccess(user.ID, user.Role)
refresh, _, _ := mgr.IssueRefresh(user.ID, user.Role)On every protected request, parse the bearer token:
claims, err := mgr.ParseAccess(bearer)
switch {
case errors.Is(err, auth.ErrExpiredToken):
return c.Status(401) // client should refresh
case errors.Is(err, auth.ErrInvalidToken):
return c.Status(401)
}
userID, role := claims.UserID, claims.RoleNew rejects a secret shorter than auth.MinSecretLen (32 bytes) unless
Config.AllowWeakSecret is set — keep that flag for tests only.
func New(cfg Config) (*Manager, error)
func (m *Manager) Issue(userID uint64, role, tokenType string, ttl time.Duration) (string, time.Time, error)
func (m *Manager) IssueAccess(userID uint64, role string) (string, time.Time, error)
func (m *Manager) IssueRefresh(userID uint64, role string) (string, time.Time, error)
func (m *Manager) Parse(token string) (*Claims, error)
func (m *Manager) ParseAccess(token string) (*Claims, error)
func (m *Manager) ParseRefresh(token string) (*Claims, error)
func (m *Manager) ParseTyped(token, expectedType string) (*Claims, error)
func (m *Manager) HashPassword(password string) (string, error)
func (m *Manager) VerifyPassword(hash, password string) bool
func (m *Manager) AccessTTL() time.Duration
func (m *Manager) RefreshTTL() time.DurationClaims embeds jwt.RegisteredClaims, so iss/sub/exp/iat/nbf/jti
are populated and verified automatically; the lagodev-specific fields are:
type Claims struct {
UserID uint64 `json:"uid"`
Role string `json:"role,omitempty"`
Type string `json:"typ,omitempty"` // "access" / "refresh" / custom
jwt.RegisteredClaims
}ParseAccess/ParseRefresh are sugar over ParseTyped(token, TokenAccess) /
ParseTyped(token, TokenRefresh) — they reject a refresh token presented where
an access token is expected (and vice versa). Define your own type string
(e.g. "api") and call Issue / ParseTyped directly when you need a third
class.
For server-rendered apps you want a cookie, not a bearer token. The session
package persists per-request data under an opaque ID carried in an
HttpOnly + Secure cookie; auth/guard layers the notion of an
authenticated user on top.
import "github.com/devituz/lagodev/session"
store := session.NewMemoryStore(2 * time.Hour)
defer store.Close()
mgr := session.NewManager(store, session.Options{
CookieName: "myapp_session",
TTL: 2 * time.Hour,
SameSite: http.SameSiteLaxMode,
// Insecure: true, // ONLY for local HTTP dev
})
// Wrap your handlers; the middleware loads/saves the session per request.
handler := mgr.Middleware()(mux)Inside a handler, pull the session off the request:
sess := session.FromRequest(r) // nil if middleware not applied
sess.Put("locale", "uz")
loc := sess.GetString("locale")
v, ok := sess.Get("cart")
sess.Forget("flash")
_ = sess.Save(ctx, w) // middleware also saves on the way outSession also exposes All(), Flush(), ID(), IsNew(),
Regenerate(ctx) (rotate the ID, defeating fixation) and
Destroy(ctx, w) (drop the record and clear the cookie). It is a per-request
handle and is not safe for concurrent use across goroutines.
Options defaults are production-correct without setup: cookie
lagodev_session, TTL 2h, Secure=true, SameSite=Lax. Note the inverted
Insecure flag — you opt out of Secure, never into it.
Guard ties a *session.Manager to a UserProvider. Implement two tiny
interfaces and the guard handles login, logout, and current-user resolution.
import "github.com/devituz/lagodev/auth/guard"
// 1. Your user model satisfies guard.User.
type User struct{ ID, Hash string }
func (u User) AuthID() string { return u.ID }
func (u User) AuthPasswordHash() string { return u.Hash }
// 2. A provider resolves users from your store.
type provider struct{ db *database.Connection }
func (p provider) FindByID(ctx context.Context, id string) (guard.User, bool, error) {
u, err := orm.Query[User](p.db).Where("id", "=", id).First(ctx)
if errors.Is(err, orm.ErrNotFound) { return nil, false, nil }
return u, err == nil, err
}
func (p provider) FindByCredentials(ctx context.Context, login string) (guard.User, bool, error) {
u, err := orm.Query[User](p.db).Where("email", "=", login).First(ctx)
if errors.Is(err, orm.ErrNotFound) { return nil, false, nil }
return u, err == nil, err
}
g := guard.New(mgr, provider{db}, guard.Options{
LoginPath: "/login", // Middleware redirects here instead of 401
})Login, inspect, and log out:
// POST /login — verifies the password via the guard's Hasher (bcrypt default).
user, err := g.Attempt(ctx, w, r, form.Email, form.Password)
if errors.Is(err, guard.ErrInvalidCredentials) {
// unknown user AND wrong password are indistinguishable (no enumeration)
}
g.Check(r) // bool: is the request authenticated?
g.ID(r) // the stored opaque user id ("" if none)
u, err := g.User(ctx, r) // re-resolves via the provider, cached per request
_ = g.Logout(ctx, w, r) // flushes the session, drops the cookieTwo middlewares enforce access:
mux.Handle("/dashboard", g.Middleware()(dashboard)) // 401/redirect if guest
mux.Handle("/login", g.Guest()(loginPage)) // 403/redirect if logged inSecurity properties are built in: Attempt/Login regenerate the session ID
before writing the authenticated id (session-fixation defence), the session
stores only the opaque id (the user is re-resolved per request), and password
verification runs through a pluggable Hasher:
type Hasher interface { Verify(hash, plain string) bool }The default is guard.BcryptHasher{}. Supply your own (argon2, scrypt, or a
legacy format you're migrating off) via Options.Hasher — it must compare in
constant time.
JWT vs. session. Use the JWT
Managerfor stateless APIs that carry a bearer token. Use the sessionGuardfor browser apps that ride on a cookie. They are independent; nothing forces you to pick only one.
auth/token implements Sanctum-style API keys: opaque, scoped, hashed at rest.
The plaintext is shown to the client exactly once at creation; the server stores
only hex(SHA-256(secret)). The wire format is "<id>|<secret>", so lookup is
O(1) by id and the secret is hash-compared in constant time — no row scans.
import "github.com/devituz/lagodev/auth/token"
issuer := token.NewIssuer(token.NewMemoryStore())
// Issue a token for user 7, scoped to two abilities, never-expiring (ttl 0).
rec, plain, err := issuer.Issue(ctx, 7, "CI deploy key",
[]string{"posts:read", "posts:write"}, 0)
// → hand `plain` (e.g. "abc123|xYz...") to the client ONCE; store nothing else.
// On an incoming request: resolve and authorize.
pat, err := issuer.Find(ctx, bearer)
switch {
case errors.Is(err, token.ErrNotFound), errors.Is(err, token.ErrMalformed):
return c.Status(401)
case errors.Is(err, token.ErrExpired), errors.Is(err, token.ErrRevoked):
return c.Status(401)
}
if pat.Cant("posts:write") {
return c.Status(403)
}Find also stamps LastUsedAt. Management and revocation:
list, _ := issuer.List(ctx, 7) // all tokens for a user (incl. revoked)
_ = issuer.Revoke(ctx, pat.ID) // by id
_ = issuer.RevokePlain(ctx, bearer) // by the presented plaintextAbilities are arbitrary scope strings; the wildcard token.AbilityAll ("*")
makes Can(x) true for any x. The PersonalAccessToken record exposes
Can, Cant, Expired, and Revoked helpers.
Swap MemoryStore for a DB-backed Store in production — the interface is four
methods (Save, Get, Delete, ListByUser).
auth/oauth runs the authorization-code flow with mandatory PKCE (S256)
using only the standard library. Prebuilt constructors fill the well-known
endpoints for Google and GitHub; Generic accepts any OIDC/OAuth2 endpoint set.
import "github.com/devituz/lagodev/auth/oauth"
p := oauth.Google(oauth.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://app.test/auth/google/callback",
// Scopes optional — Google defaults to "openid email profile".
})Mint a PKCE verifier and a CSRF state, stash both in the session, then redirect:
func redirect(w http.ResponseWriter, r *http.Request) {
verifier, _ := oauth.NewVerifier()
state, _ := oauth.NewState()
sess := session.FromRequest(r)
sess.Put("oauth_verifier", verifier)
sess.Put("oauth_state", state)
_ = sess.Save(r.Context(), w)
url := p.AuthCodeURL(state, oauth.Challenge(verifier))
http.Redirect(w, r, url, http.StatusFound)
}Only the SHA-256 challenge travels in the redirect; an intercepted authorization code is useless without the verifier.
Verify state, then exchange the code (presenting the verifier) and fetch the identity:
func callback(w http.ResponseWriter, r *http.Request) {
sess := session.FromRequest(r)
if r.URL.Query().Get("state") != sess.GetString("oauth_state") {
http.Error(w, "bad state", http.StatusBadRequest)
return
}
verifier := sess.GetString("oauth_verifier")
tok, err := p.Exchange(r.Context(), r.URL.Query().Get("code"), verifier)
if err != nil { /* oauth.ErrTokenResponse, ... */ }
user, err := p.User(r.Context(), tok) // normalised identity
// user.ID, user.Name, user.Email, user.Avatar, user.Raw (full payload)
_ = upsertAndLogin(user)
}Token carries AccessToken, TokenType, RefreshToken, IDToken (raw OIDC
JWT when present — this package does not verify it), and Expiry. User
is the normalised shape; Raw preserves the provider's full payload for fields
outside the common set.
If you cannot persist per-request state, use an HMAC-signed, self-expiring state
value instead of NewState:
signer := oauth.NewStateSigner([]byte(os.Getenv("APP_KEY")))
state, _ := signer.SignedState(5 * time.Minute) // step 1
// ...
err := signer.VerifyState(r.URL.Query().Get("state")) // step 2, constant-timep := oauth.GitHub(oauth.Config{ClientID: id, ClientSecret: sec, RedirectURL: cb})
// Any OIDC/OAuth2 provider; nil mapper falls back to standard OIDC claims.
p := oauth.Generic(cfg, authURL, tokenURL, userInfoURL, func(raw []byte) (oauth.User, error) {
var v struct{ Sub, Name, Mail string }
_ = json.Unmarshal(raw, &v)
return oauth.User{ID: v.Sub, Name: v.Name, Email: v.Mail}, nil
})
// Inject an *http.Client (timeouts, test transports); nil → http.DefaultClient.
p = p.WithHTTPClient(&http.Client{Timeout: 10 * time.Second})auth/account covers the credential lifecycle around login: password-reset and
email-verification tokens, signed URLs, and a login throttler.
Reset and verification tokens are high-entropy random values; the store keeps
only a SHA-256 hash, and Consume succeeds at most once (defeating replay).
Purpose namespaces the tokens so a reset token can never be replayed as a
verification token.
import "github.com/devituz/lagodev/auth/account"
tokens := account.NewTokens(account.NewMemoryTokenStore(), time.Hour)
// "Forgot password" → mail this plaintext in a reset link.
plain, _ := tokens.Issue(ctx, account.PurposeReset, "user-42")
// On the reset form submit:
subject, err := tokens.Consume(ctx, account.PurposeReset, plain)
switch {
case errors.Is(err, account.ErrTokenNotFound): // wrong/used token
case errors.Is(err, account.ErrTokenExpired): // past TTL
default:
resetPasswordFor(subject) // "user-42"
}Use Verify (read-only, does not burn the token) when you want to validate
before showing the form, and Consume on submit. account.PurposeVerify is the
email-verification namespace; the pattern is identical.
Signer produces tamper-proof, self-expiring links with an HMAC over the path,
a canonical sorted encoding of the query, and the expiry — so reordering or
adding parameters invalidates the signature.
signer := account.NewSigner([]byte(os.Getenv("APP_KEY")))
link, _ := signer.Sign("https://app.test/invite?team=acme", time.Hour)
// → original URL plus signature & expiry query params.
err := signer.Verify(link) // constant-time
// account.ErrInvalidSignature | account.ErrSignatureExpired | nilThrottle is a fixed-window failure counter keyed by an arbitrary string. After
maxAttempts failures within the window the key is locked; a successful login
must Clear it.
th := account.NewThrottle(5, time.Minute) // 5 tries / minute
key := "login:" + email
if err := th.Check(key); errors.Is(err, account.ErrThrottled) {
return c.Status(429)
}
if !passwordOK {
_ = th.Hit(key) // record a failure (also returns ErrThrottled when it trips)
return c.Unauthorized()
}
th.Clear(key) // success: reset the counterAttempts(key) returns the current count if you want to surface "N tries left".
- Secret length.
auth.Newrequires a >= 32-byteSecret; reuse the sameAPP_KEYbytes foroauth.NewStateSignerandaccount.NewSigner. Never setAllowWeakSecretoutside tests. - Swap the in-memory stores.
session.MemoryStore,token.MemoryStore, andaccount.MemoryTokenStoreare single-replica and lose state on restart. Behind a load balancer or for durability, implement theStore/TokenStoreinterface over SQL or Redis — call sites don't change. - Cookies are Secure by default.
session.Options.Insecure=truedisables theSecureflag; flip it only on local HTTP. KeepSameSite=Lax(orStrict) and never widen it without reason. - Session fixation is handled by the guard (ID regenerated on login). If you
elevate privileges mid-session by other means, call
sess.Regenerate(ctx)yourself. - No user enumeration.
guard.ErrInvalidCredentialsis returned for both an unknown identifier and a wrong password — keep them indistinguishable in your HTTP responses too, and run the throttler on the identifier. - Tokens at rest are hashes. A leak exposes no usable credential (bcrypt for passwords, SHA-256 for PATs and reset/verify tokens). Show a PAT or reset token plaintext exactly once.
- OAuth: verify the
IDTokenyourself. This package mints/checks PKCE and (optionally) signs state, but does not validate the OIDCid_tokensignature — verify it against the provider's JWKS before trusting its claims. - Throttler is in-memory and per-process. For multi-replica deployments,
back the rate limit with a shared store (Redis
INCR/EXPIRE).