go-auth

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

PackageResponsibility
domainCore types (User, Session, Organization, ...) and every AuthError — no dependencies on anything else in the module.
portInterfaces the service layer depends on: Mailer, Hasher, TokenGenerator, TemplateProvider, OAuthProvider, TxManager, and one repository interface per aggregate (UserRepository, SessionRepository, OrgRepository, ...).
serviceBusiness 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.
sqlstoreThe port repository interfaces implemented over database/sql — one _repo.go per aggregate, plus the DB wrapper described below.
handlerHTTP 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.
middlewareCross-cutting HTTP concerns: AuthMiddleware/RequireRole (session and role gating), OriginCheck/CSRFToken (CSRF), RateLimit, CORS, RequireOrgMember/RequireOrgRole (org access control).
providerBuilt-in OAuth provider adapters (provider/google, provider/github), each implementing port.OAuthProvider.
ratelimitThe rate limiter itself: Config, the Store interface, and the built-in in-memory implementation.
auditThe audit event pipeline: AuditService (queue + workers + batching), the EventSink interface, and the built-in sinks.
emailtemplateThe default TemplateProvider — embedded HTML/text templates for every email the library sends.
tokenCryptographically random opaque token generation (crypto/rand, 32 bytes, hex-encoded).
hasherThe Hasher implementation — bcrypt, configurable cost.
internalUnexported 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:

  1. Opens (or adopts) the database connection based on DatabaseConfig, and wraps it in sqlstore.DB — a thin *sql.DB wrapper that also implements port.TxManager.
  2. Constructs one sqlstore repository per aggregate, all sharing the same *sqlstore.DB.
  3. Constructs the mailer (WithMailer, or WithEmail's built-in SMTP client, or none) and the template provider (WithTemplates, or the built-in emailtemplate provider).
  4. Constructs every service.*Service, injecting the repositories, mailer, template provider, hasher, and token generator each one needs.
  5. Builds the middleware instances (authMW, adminMW, rateLimitMW, csrfMW, csrfTokenMW, corsMW, orgMemberMW, orgAdminMW, orgOwnerMW) once, so every handler that needs one shares the same instance.
  6. Wraps each HTTP handler in its specific middleware chain (see below) and assembles the result into HandlerGroup.
  7. Returns an *Auth exposing Services (the raw service methods, for calling programmatically without HTTP — see the next section), Handlers (pre-wrapped http.HandlerFuncs), and Middleware (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:

  • Programmaticallyauth.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 HTTPauth.Mount(mux) registers the same operations behind the full middleware chain, with handler translating 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) → handler

Not every route uses every layer — a layer is only present when the route needs it:

  • CORS is outermost on every route, so a preflight OPTIONS request 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 into Auth.
  • 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.Context for 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) → handler

Organizations access model

ActionRequires
Read an org, list its membersMembership
Update or delete the org, manage members/roles, create invitesMembership + admin or owner role (delete requires owner)
Leave the orgMembership only
Create an org, list your own orgs, accept an invite, set/clear your active orgAuthenticated 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.Rebind rewrites them to $1, $2, ... for PostgreSQL and leaves them as ? for MySQL/SQLite.
  • Transactionssqlstore.DB implements port.TxManager. WithTx(ctx, fn) starts a real *sql.Tx, stores it in the context.Context, and every repository call within fn checks the context for an active transaction and routes through it instead of the pooled connection. Calling WithTx again from inside fn joins 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:

InterfaceBuilt-in implementationReplace it for
port.MailerSMTP via WithEmail, or noneResend, Postmark, SES, or any transactional email API — see Configuration
port.TemplateProvideremailtemplate (embedded HTML/text templates)Your own branded email templates
port.OAuthProviderprovider/google, provider/githubAny OAuth2 provider — Name(), AuthURL(), Exchange()
ratelimit.Storein-memoryA distributed store (Redis, etc.) for multi-instance deployments
audit.EventSinkthe SQL sink (persists to the audit_log table)Kafka, NATS, a webhook — added via WithAuditSink alongside the built-in one
port.Hasherbcrypt (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.

On this page