Architecture
How go-auth is put together internally — packages, layering, the middleware chain, and how a single New() call wires it all up.
Architecture
go-auth follows a ports-and-adapters (hexagonal) layout. Business logic in the service package never imports a database driver or an HTTP type directly — it depends on small interfaces defined in port, and concrete implementations of those interfaces (a Postgres/MySQL/SQLite repository, an SMTP mailer, a Google or GitHub OAuth client) are wired in once, at startup, in auth.go's New(). Swapping an adapter — a different mailer, a Redis-backed rate limit store, a custom email template — never touches the service layer.
Package map
| Package | Responsibility |
|---|---|
domain | Core types (User, Session, Organization, ...) and every AuthError — no dependencies on anything else in the module. |
port | Interfaces the service layer depends on: Mailer, Hasher, TokenGenerator, TemplateProvider, OAuthProvider, TxManager, and one repository interface per aggregate (UserRepository, SessionRepository, OrgRepository, ...). |
service | Business logic — one file per concern (auth.go, password.go, session_service.go, admin.go, oauth.go, org.go, org_invite.go, invite.go, verification.go). Depends only on domain and port. |
sqlstore | The port repository interfaces implemented over database/sql — one _repo.go per aggregate, plus the DB wrapper described below. |
handler | HTTP adapters — decodes requests, calls a service method, writes the JSON response or sets cookies. Three files: handler.go (auth/account/session/admin/invite), oauth.go, org.go. |
middleware | Cross-cutting HTTP concerns: AuthMiddleware/RequireRole (session and role gating), OriginCheck/CSRFToken (CSRF), RateLimit, CORS, RequireOrgMember/RequireOrgRole (org access control). |
provider | Built-in OAuth provider adapters (provider/google, provider/github), each implementing port.OAuthProvider. |
ratelimit | The rate limiter itself: Config, the Store interface, and the built-in in-memory implementation. |
audit | The audit event pipeline: AuditService (queue + workers + batching), the EventSink interface, and the built-in sinks. |
emailtemplate | The default TemplateProvider — embedded HTML/text templates for every email the library sends. |
token | Cryptographically random opaque token generation (crypto/rand, 32 bytes, hex-encoded). |
hasher | The Hasher implementation — bcrypt, configurable cost. |
internal | Unexported helpers not part of the public API (test utilities, low-level crypto/keyring helpers). |
The dependency direction is strict: domain depends on nothing, port depends only on domain, service depends only on domain and port, and sqlstore/handler/middleware/provider depend on service and port to implement or consume them. Nothing in service imports sqlstore, handler, or net/http.
The Auth struct and New()
New(cfg config) (*Auth, error) in auth.go is the composition root — the one place every adapter gets constructed and handed to a service. In order, it:
- Opens (or adopts) the database connection based on
DatabaseConfig, and wraps it insqlstore.DB— a thin*sql.DBwrapper that also implementsport.TxManager. - Constructs one
sqlstorerepository per aggregate, all sharing the same*sqlstore.DB. - Constructs the mailer (
WithMailer, orWithEmail's built-in SMTP client, or none) and the template provider (WithTemplates, or the built-inemailtemplateprovider). - Constructs every
service.*Service, injecting the repositories, mailer, template provider, hasher, and token generator each one needs. - Builds the middleware instances (
authMW,adminMW,rateLimitMW,csrfMW,csrfTokenMW,corsMW,orgMemberMW,orgAdminMW,orgOwnerMW) once, so every handler that needs one shares the same instance. - Wraps each HTTP handler in its specific middleware chain (see below) and assembles the result into
HandlerGroup. - Returns an
*AuthexposingServices(the raw service methods, for calling programmatically without HTTP — see the next section),Handlers(pre-wrappedhttp.HandlerFuncs), andMiddleware(the shared middleware instances, in case you want to apply one to your own routes).
Auth.Mount(mux *http.ServeMux) only registers routes on the mux you give it — it does not add any middleware of its own. Every handler in HandlerGroup already has its full chain baked in from step 6, and CORS preflight (OPTIONS) is registered per-path automatically wherever WithSecurity.AllowedOrigins is non-empty.
Two ways to drive it
Because service never touches net/http, every operation is reachable two ways:
- Programmatically —
auth.Services.Auth.Register(ctx, ...),auth.Services.Admin.BanUser(ctx, ...), and so on. No cookies, no CSRF, no rate limiting — you get back plain Go values and are responsible for whatever transport you're building (a CLI, a gRPC service, a background job). - Over HTTP —
auth.Mount(mux)registers the same operations behind the full middleware chain, withhandlertranslating cookies and JSON into service calls.
The middleware chain
Every entry in HandlerGroup is a handler wrapped in some subset of six middleware, applied in the same relative order every time (outermost to innermost):
CORS → RateLimit → CSRFToken → OriginCheck → Auth → (Admin | OrgMember → OrgRole) → handlerNot every route uses every layer — a layer is only present when the route needs it:
- CORS is outermost on every route, so a preflight
OPTIONSrequest short-circuits before it can be rate-limited or otherwise rejected. - RateLimit wraps routes with meaningful abuse potential — login, register, password reset, invite creation — not read-only routes like
GET /auth/me. - CSRFToken and OriginCheck wrap every state-changing route (anything that isn't a plain
GET), whether or not the caller is authenticated yet — origin checking has to happen before a session even exists, which is why it isn't folded intoAuth. - Auth wraps anything that needs to know who's calling — it resolves the session cookie, transparently refreshes an expired session using the refresh cookie if present, and puts the user and session in
context.Contextfor the handler. - Admin (
RequireRole(domain.RoleAdmin)) additionally wraps every/admin/*route. - OrgMember / OrgRole additionally wrap
/auth/orgs/{orgID}/*routes: membership is required to read, a minimum role (admin or owner) is required to mutate — see the table in Organizations below.
Four concrete examples, outermost first:
Register (public): CORS → RateLimit → CSRFToken → OriginCheck → handler
ChangeName (authenticated): CORS → CSRFToken → OriginCheck → Auth → handler
BanUser (admin): CORS → RateLimit → CSRFToken → OriginCheck → Auth → Admin → handler
UpdateOrg (org admin): CORS → CSRFToken → OriginCheck → Auth → OrgMember → OrgRole(admin) → handlerOrganizations access model
| Action | Requires |
|---|---|
| Read an org, list its members | Membership |
| Update or delete the org, manage members/roles, create invites | Membership + admin or owner role (delete requires owner) |
| Leave the org | Membership only |
| Create an org, list your own orgs, accept an invite, set/clear your active org | Authenticated only — there's no {orgID} path segment to check membership against yet, or the service layer checks it internally |
Database layer
The library targets database/sql for MySQL and SQLite, and pgx/v5 (either through its database/sql shim or a native pgxpool.Pool) for PostgreSQL. sqlstore.DB wraps whichever one you gave it behind a single interface every repository uses, and handles two cross-driver differences transparently:
- Placeholder syntax — queries are written with
?placeholders;DB.Rebindrewrites them to$1, $2, ...for PostgreSQL and leaves them as?for MySQL/SQLite. - Transactions —
sqlstore.DBimplementsport.TxManager.WithTx(ctx, fn)starts a real*sql.Tx, stores it in thecontext.Context, and every repository call withinfnchecks the context for an active transaction and routes through it instead of the pooled connection. CallingWithTxagain from insidefnjoins the existing transaction rather than nesting one, so service code can compose transactional operations without knowing whether it's already inside one.
Extension points
Everywhere the library needs to talk to the outside world, it does so through a port interface, so you can substitute your own implementation without forking:
| Interface | Built-in implementation | Replace it for |
|---|---|---|
port.Mailer | SMTP via WithEmail, or none | Resend, Postmark, SES, or any transactional email API — see Configuration |
port.TemplateProvider | emailtemplate (embedded HTML/text templates) | Your own branded email templates |
port.OAuthProvider | provider/google, provider/github | Any OAuth2 provider — Name(), AuthURL(), Exchange() |
ratelimit.Store | in-memory | A distributed store (Redis, etc.) for multi-instance deployments |
audit.EventSink | the SQL sink (persists to the audit_log table) | Kafka, NATS, a webhook — added via WithAuditSink alongside the built-in one |
port.Hasher | bcrypt (hasher package) | Not currently swappable via a With* option — it's constructed internally with a fixed cost |
Testing structure
Unit tests live alongside the code they test (service/*_test.go, handler/*_test.go, ...) and use hand-written fakes for the port interfaces (see internal/testutil), so they run without a database. The integration package runs the same flows against real PostgreSQL, MySQL, and SQLite databases behind build-tag-free _test.go files gated on environment variables (e.g. GOAUTH_POSTGRES_DSN) — they're skipped, not failed, when the corresponding database isn't available.