authit

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT

README

authit

A reusable Go library for user authentication, superuser (operator) authentication, and team/organization-based auth — with no assumption about which database you use.

authit was designed by studying two existing implementations (a full-featured but Postgres-locked product auth system, and a modular monorepo's shared auth packages) and combining the best parts: the modular package split of the latter, the feature completeness of the former, plus a storage-port layer neither of them had.

Design

  • No database assumption. Every package that touches persistence depends only on interfaces defined in store. A host application implements those interfaces against whatever it uses — Postgres, SQLite, DynamoDB, or nothing at all. memstore ships a reference in-memory implementation of every interface, so the library is usable and testable out of the box.
  • No social/OAuth login. Email + password (+ optional TOTP) only, by design, for now.
  • Three independent planes, one shared crypto/JWT layer:
    • user — registration, login, sessions, password reset, email verification, TOTP/2FA.
    • team — organizations, membership, roles, invitations.
    • superuser — a structurally separate operator identity, kept apart from user by JWT audience (not a separate secret), with impersonation.
  • CLI/non-interactive auth is a separate concern from browser sessions. pat (personal access tokens) and device (RFC 8628 device-authorization-grant) don't mint JWTs — they resolve who is asking, and leave it to the host application to decide what credential to hand back.
  • Authorization is the caller's job. team methods that change roles or remove members do not check the caller's own role — a host application resolves the caller's Member (via GetMemberByUserAndTeam) and checks it before calling. This keeps authit's authorization model unopinionated about your app's specific rules.
  • Roles are per-team, and only per-team. A Role exists on a Member, and a Member exists in a Team, so team cannot express a principal whose identity spans teams — a platform auditor, a consultant, a support engineer working across many client organizations. That's deliberate, not a gap: such an identity belongs in your own schema, joined to authit by user id. If you find yourself inventing a team that every privileged user joins, or writing one membership row per team to express a single global capability, you're fighting the model — and both workarounds break the moment that principal must reach a team it holds no membership in. authit answers who is this; your model answers what may they do.

Packages

Package Purpose
crypto Password hashing (bcrypt), opaque token generation/hashing, TOTP, AES-256-GCM secret encryption, ID generation
jwt JWT signing/verification (Signer interface, HMAC-SHA256 implementation), Claims
store Storage-port interfaces — the contract a host application implements
memstore In-memory implementation of every store interface, for tests and quick starts
user Registration, login/logout/refresh, sessions, password reset, email verification, TOTP/2FA
team Teams, membership, roles, invitations
superuser Operator accounts, login/refresh, deactivation, impersonation
pat Personal access tokens — named, scoped, optionally-expiring bearer credentials for CLIs/scripts
device RFC 8628 OAuth 2.0 Device Authorization Grant — "visit this URL, enter this code" CLI login
authithttp The only HTTP wiring authit ships: RFC-correct bearer-token extraction, validation, and 401-vs-500 classification
audit Opt-in security-event logging (Logger, Event, NoopLogger, SlogLogger) — every service's Config carries a nil-safe AuditLogger

Quick start

import (
	authitjwt "github.com/mind-vm/authit/jwt"
	"github.com/mind-vm/authit/memstore"
	"github.com/mind-vm/authit/user"
)

signer, _ := authitjwt.NewHMACSigner(jwtSecret, authitjwt.Defaults{Issuer: "myapp"})

stores := user.Stores{
	Users:              memstore.NewUserStore(),       // swap for your own store.UserStore
	RefreshTokens:      memstore.NewRefreshTokenStore(),
	PasswordResets:     memstore.NewPasswordResetStore(),
	EmailVerifications: memstore.NewEmailVerificationStore(),
	TOTP:               memstore.NewTOTPStore(),
	PendingTwoFactor:   memstore.NewPendingTwoFactorStore(),
	Lockouts:           memstore.NewLockoutStore(),
}

svc, _ := user.NewService(stores, signer, myEmailSender, user.Config{
	TOTPEncryptionKey: totpKey, // 32 bytes, required if you use 2FA
})

u, err := svc.Register(ctx, "alice@example.com", "correct horse battery staple")
result, err := svc.Authenticate(ctx, "alice@example.com", "password", userAgent, ip)
if result.RequiresTwoFactor {
	result, err = svc.VerifyTwoFactorLogin(ctx, result.PendingTwoFactorToken, code, userAgent, ip)
}
// result.Tokens.AccessToken / result.Tokens.RefreshToken

team and superuser follow the same shape: define Stores, construct with NewService, call methods.

CLI auth (pat / device)
patSvc, _ := pat.NewService(pat.Stores{Tokens: myTokenStore}, pat.Config{Prefix: "mb_"})
raw, token, err := patSvc.CreateToken(ctx, userID, "laptop", []string{"read", "write"}, nil)
// raw is shown to the user once; only its hash is stored.
resolved, err := patSvc.Resolve(ctx, incomingBearerToken) // on every request

deviceSvc, _ := device.NewService(device.Stores{Authorizations: myDeviceStore}, device.Config{})
auth, err := deviceSvc.StartDeviceAuthorization(ctx, "cli", "read write")
// show auth.UserCode + your own verification URL to the CLI user

// from an authenticated web session, once the user enters the code:
deviceSvc.ApproveDeviceAuthorization(ctx, callerUserID, userCode)

// the CLI polls:
userID, scope, err := deviceSvc.PollDeviceToken(ctx, auth.DeviceCode)
// on device.ErrAuthorizationPending / ErrSlowDown, wait auth.Interval (bumping it on
// ErrSlowDown) and poll again; on success, mint whatever credential you want (a pat
// token, a user session, ...) for userID.
Email verification

By default Authenticate refuses an account whose address isn't verified, returning ErrEmailNotVerified. That's the right default for self-serve signup, but it isn't the right policy everywhere — an emailed, tokenised B2B invite already proves the address, SSO provisioning arrives pre-verified, and seeded demo/test accounts want it off entirely. So it's a knob, not a law:

user.Config{EmailVerification: user.EmailVerificationOptional} // default is ...Required

Relaxing the gate doesn't touch the flag — User.EmailVerified is still tracked, so your own features can still depend on it; only login stops doing so.

For the paths where the address really is already proven, mark it directly instead of minting and redeeming a token:

svc.MarkEmailVerified(ctx, u.ID) // seeders, accepted invites, SSO provisioning

It's idempotent, and it kills any verification link already sitting in an inbox. Never call it from an unauthenticated path — that's the check VerifyEmail exists to perform.

HTTP: extracting and validating a bearer token

authit is a service layer, not a web framework, with one exception. Pulling a bearer token off a request and validating it is identical in every consumer and quietly security-critical: strings.TrimPrefix(h, "Bearer ") turns a malformed header into a token rather than a rejection, the scheme is case-insensitive per RFC 7235 so a naive prefix check rejects valid requests, and "no token" and "bad token" are both 401 while "the signer can't verify anything" is a 500. So that one piece is tested here instead of approximated everywhere:

import "github.com/mind-vm/authit/authithttp"

claims, err := authithttp.Validate(signer, r)
if err != nil {
	w.WriteHeader(authithttp.StatusFor(err)) // 401 or 500 — body is yours
	return
}
// claims.Subject is the user ID.

That's the whole package: BearerToken, Validate, StatusFor. No http.Handler, no context key, no opinion about your error envelope. Note that Validate accepts an impersonation token (claims.IsImpersonation(), minted via superuser.Impersonate) — it's genuine, so whether acting-as is allowed on a given route is yours to check. If you want revocation to take effect before token expiry, re-resolve the principal from your own storage and treat claims beyond the subject as hints.

Ready-made HTTP routes (authhandlers)

If you'd rather not hand-write the request/response plumbing for every user flow, authhandlers is a separate module (own go.mod, like sqlbstore) that wraps user.Service in a mountable route group — register, login, refresh, logout, password reset, email verification, 2FA, and session management. It depends on nothing beyond net/http and authit itself: no chi, no huma, no OpenAPI generator. NewUserHandler returns a plain http.Handler (a *http.ServeMux using Go 1.22's method+pattern routing), which you mount wherever you like:

import "github.com/mind-vm/authit/authhandlers"

mux := http.NewServeMux()
mux.Handle("/auth/", http.StripPrefix("/auth", authhandlers.NewUserHandler(userSvc, signer)))

Protected routes (session management, password change, 2FA management) validate the caller's bearer token themselves against the same signer you pass in — no host middleware or context key required. CORS, rate limiting, and request logging are still yours; this package stops at request/response JSON and status codes. It covers the user plane only for now — team, superuser, pat, and device follow the same pattern if you need routes for those too.

Audit logging

Every service's Config carries an AuditLogger audit.Logger field. Leaving it nil (the zero value) means events are simply not recorded — the same opt-in shape as user.Config's EmailSender or team.Config's Admission. Logins, lockouts, password/2FA changes, session and token revocation, and impersonation all go through it:

import (
	"log/slog"

	"github.com/mind-vm/authit/audit"
)

userSvc, _ := user.NewService(stores, signer, emailer, user.Config{
	AuditLogger: audit.SlogLogger{Logger: slog.Default()},
})

audit.SlogLogger covers the common case of wanting these events in application logs (ResultFailure/ResultDenied log at Warn, everything else at Info). For a compliance trail (SOC2, GDPR, PCI-DSS) or a dedicated event pipeline, implement audit.Logger yourself — it's one method, Log(ctx, audit.Event), and takes no error return: delivery guarantees (retry, buffering, an outbox) are the implementation's concern, not authit's, and a logging failure never affects the outcome of the operation being audited.

Database schema

authit ships no DDL and no migrations — every package depends only on the store interfaces, and your schema is yours. But the required table set shouldn't have to be reverse-engineered from struct definitions one type at a time, so there's a reference:

  • schema.sql — a complete, non-binding Postgres table set for all fifteen tables behind every store interface, annotated at the places where the columns aren't guessable from the Go types. Rename anything; nothing reads this file.
  • sqlbstore/example_test.go — that same schema wired end to end through sqlbstore: a row type and a filled-in Table[R, T] for every store in user.Stores, ending in a working user.Service. It applies schema.sql and runs the real flows over it, so the reference schema is checked by the test suite rather than merely asserted.

Three things store/*.go will not tell you, and the reason the reference exists:

  • LockoutStore needs two tables. The second — the set of currently-locked accounts — has no authit type at all, so nothing in store/user.go hints it exists. Implement only the attempts table and it compiles cleanly, then fails at runtime.
  • store.TOTPSettings does not use the column names you'd guess: the fields are Enabled, VerifiedAt, RecoveryCodeHashes and RecoveryCodesUsed — not confirmed and backup_codes.
  • RecoveryCodeHashes is a []string with no obvious storage. text[], a join table and JSON are all fine; the choice is silently yours and it changes your adapter.

What's deliberately not included

  • HTTP handlers/routing. authit is a service layer, not a web framework — wire it into your own router (chi, net/http, huma, ...). authithttp is the one concession, and it stops at parsing and validating a bearer token.
  • Email delivery. user.EmailSender is an interface; bring your own SMTP/API client.
  • Social/OAuth login, RBAC policy engines. Out of scope for now; team.Role and store.Member.Role are plain strings you can extend.

Status

Early scaffold. Core flows are implemented and tested (see go test ./...), but this has not yet been used in a production app.

License

MIT — see LICENSE.

Directories

Path Synopsis
Package audit lets a host observe authit's security-relevant events — logins, lockouts, password and 2FA changes, session and token revocation, impersonation — without authit dictating where they end up.
Package audit lets a host observe authit's security-relevant events — logins, lockouts, password and 2FA changes, session and token revocation, impersonation — without authit dictating where they end up.
authhandlers module
Package authithttp is the only HTTP wiring authit ships: RFC-correct bearer-token extraction, token validation, and the classification of what went wrong.
Package authithttp is the only HTTP wiring authit ships: RFC-correct bearer-token extraction, token validation, and the classification of what went wrong.
Package crypto provides the low-level primitives authit's service packages build on: password hashing, opaque token generation/hashing, TOTP enrollment/validation, and secret-at-rest encryption.
Package crypto provides the low-level primitives authit's service packages build on: password hashing, opaque token generation/hashing, TOTP enrollment/validation, and secret-at-rest encryption.
Package device implements RFC 8628's OAuth 2.0 Device Authorization Grant: the "visit this URL and enter this code" flow used by headless or browser-less CLIs (no local browser or listening port assumed — see https://datatracker.ietf.org/doc/html/rfc8628).
Package device implements RFC 8628's OAuth 2.0 Device Authorization Grant: the "visit this URL and enter this code" flow used by headless or browser-less CLIs (no local browser or listening port assumed — see https://datatracker.ietf.org/doc/html/rfc8628).
Package jwt provides authit's token signing/verification, kept deliberately free of any notion of users, sessions, or storage — it knows only how to turn claims into a signed string and back.
Package jwt provides authit's token signing/verification, kept deliberately free of any notion of users, sessions, or storage — it knows only how to turn claims into a signed string and back.
Package memstore is a reference in-memory implementation of every interface in the store package.
Package memstore is a reference in-memory implementation of every interface in the store package.
Package pat implements personal access tokens: long-lived, named, scoped bearer credentials a user creates for themselves (a CLI, a script, an integration) outside of an interactive login.
Package pat implements personal access tokens: long-lived, named, scoped bearer credentials a user creates for themselves (a CLI, a script, an integration) outside of an interactive login.
sqlbstore module
Package store defines the persistence ports that authit's service packages depend on.
Package store defines the persistence ports that authit's service packages depend on.
Package superuser implements a system-operator identity plane that is structurally separate from the user package: its own table (via SuperuserStore), its own claims type, and — critically — its own JWT audience, so a perfectly valid user access token can never be accepted by superuser middleware, and vice versa, even though both planes may share the same underlying jwt.Signer/secret.
Package superuser implements a system-operator identity plane that is structurally separate from the user package: its own table (via SuperuserStore), its own claims type, and — critically — its own JWT audience, so a perfectly valid user access token can never be accepted by superuser middleware, and vice versa, even though both planes may share the same underlying jwt.Signer/secret.
Package team implements team/organization management: creating teams, managing membership and roles, and inviting new members by email.
Package team implements team/organization management: creating teams, managing membership and roles, and inviting new members by email.
Package user implements user registration, authentication, session management, password reset, email verification, and TOTP-based two-factor auth.
Package user implements user registration, authentication, session management, password reset, email verification, and TOTP-based two-factor auth.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL