Architecture
How go-auth is put together internally: packages, layering, the middleware chain, and how a single New() call wires it all up.
Architecture
Optional app authorization
config_app_permissions.go owns the opt-in functional option. Wiring constructs
AppPermissionsService and the public Services.AppPermissions capability only
when enabled. api/app_permissions.go contains operation inputs; trusted actor
and session IDs are supplied by transport context. domain/app_permissions.go
contains role/definition/catalog shapes.
The service uses narrow permission reader/writer, role and singleton-state ports
implemented by internal/sqlstore/app_permissions_repo.go and
app_roles_repo.go. Management takes the existing all-user account guard before
locking app state, reloads current actor authorization, writes changes and durable
audit records in that transaction, then advances revisions. SQL constraints
prevent duplicate grants and deletion of assigned roles/referenced definitions.
Role assignment uses the same management guards and authorization rechecks, but advances the global revision only after an actual assignment change. Same-role retries preserve assignment/global revisions and account timestamps without a role-change audit event; stale expected revisions still fail.
Ordinary account creation uses a separate transaction, without the all-user
guard or singleton write lock. The new unassigned user row is inserted before
locking the selected default role. RoleBySlugForShare uses FOR SHARE on
PostgreSQL/MySQL, so concurrent signups coexist while role disable/delete
operations wait. The initialization-state check is a plain read. The assignment
is then persisted with revision 0 -> 1 before commit. Inserting first avoids
the opposite user/role lock order with administrative user scans; invitation
redemption also inserts before claiming its invite. SQLite's insert acquires
its native writer lock before the role read. Administrative account creation
retains its management guard and actor/delegation checks.
Library catalog data lives in code and is independent of optional SQL definition
installation. HTTP handlers are thin adapters; internal/httproutes selects
operation-specific middleware. Local CLI provisioning shares the private bulk
writer through an internal collaborator with a database-administration trust
boundary. Ordinary direct service methods retain session assurance enforcement.
No permissions are cached in sessions and startup does not seed definitions.
See the usage guide and route reference.
go-auth uses ports and adapters. The service package depends on interfaces in port, not on HTTP types or database drivers. New() in auth.go wires repositories, mail delivery, and OAuth adapters at startup.
Optional organization authorization
WithOrgPermissions wires OrgPermissionsService independently of app roles.
The public capability is Services.OrgPermissions; inputs live in
api/org_permissions.go, with domain types in domain/org_permissions.go.
OrgPermissionRole is the granular role record; the existing OrgRole enum
remains the fixed owner/admin/member classification used by ownership counters.
Narrow org permission reader/writer, role/assignment and revision-state ports are implemented by SQL repositories with explicit org predicates. Composite foreign keys prevent cross-org grants and assignments. Membership-linked role records preserve the existing membership schema; unassigned memberships resolve to their protected fixed role after explicit org initialization.
Mutations acquire the existing all-user guard, then the organization row, then current authorization/membership records. This serializes management writes across accounts and organizations, a deliberate correctness tradeoff matching the existing account guard. Active-org selection and platform oversight use the same order when enabled. Database errors abort instead of being converted to a permission or not-found decision. Durable audit records join the mutation. Invitation creation/resend commit before email delivery.
With the standard organization wiring, member adds (including platform-admin adds), fixed-role changes, and self-service removals also acquire the all-user guard when granular org permissions are disabled. Retained role assignments keep role changes on this guard. Adds must acquire it before membership or organization locks: owner-count updates write users, and non-owner membership inserts lock referenced users while checking foreign keys. These operations therefore serialize with other account-guarded writes in fixed-role mode too.
Existing org services perform exact action checks before their established
counter/last-owner helpers. HTTP middleware performs corresponding prechecks;
direct Go calls retain enforcement. No permission snapshot is stored in a session.
The self org list checks organization.read separately for every returned org
before exposing its metadata and creator. These live checks can read the actor,
membership, role, session and relevant definitions/grants per row; query cost
therefore grows with the number of listed orgs. Paginate large membership lists
and avoid limit=0 on that hot path. There is no batched permission evaluation.
See org permissions and its
HTTP reference. The fixed-role access model
below describes the default mode; opt-in custom roles use explicit operation grants.
Package map
OAuth link finalization uses port.SessionReader.LockByID inside a transaction,
after acquiring the owning user lock. The SQL row lock stays held through the
provider insert; session-repository test doubles must implement this capability.
Provider exchange runs before the finalization transaction.
port.UserRepository.SetPasswordAndVerify accepts
port.SetPasswordAndVerifyInput, carrying the user, new hash/pepper version,
token ID/hash and proved email. SQL implementations lock the user and token, sample the
current time, claim the exact live set-password token and install the password
in one transaction. Existing transaction contexts are joined, so a later caller
failure rolls back both the code and password. Repository test doubles must
implement this input contract as well.
| Package | Responsibility |
|---|---|
domain | Core types (User, Session, Organization, ...) and every AuthError. No dependencies on anything else in the module. |
api | Transport-neutral inputs and results for programmatic operations, plus shared sort types, audit entries, and daily counts. Import as github.com/nazimdjebloun/go-auth/api. |
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, split by concern. Depends on api, domain, and port. |
middleware | Cross-cutting HTTP concerns: AuthMiddleware/RequireRole (session and role gating), OriginCheck/CSRFToken (CSRF), RateLimit, CORS, RequireOrgMember/RequireOrgRole (org access control, resolved from an {orgID} path segment), RequireActiveOrg (the same role check, resolved from the session's active org instead). The root facade's RequireOrgScope/RequireActiveOrgScope compose these checks and pass an authorized OrgScope directly to application handlers. |
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 durable audit pipeline: Service.Record writes the record and its outbox obligation inside the caller's transaction (successful writes commit or roll back with the change; failed writes follow the configured enqueue policy), the dispatcher claims and delivers external sinks, and the janitor enforces the outbox clocks. |
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 default bcrypt Hasher and optional hasher/argon2id implementation. Callers select the latter through WithPasswordHashing's Hasher field; wiring always adds prefix dispatch and a password pipeline whose new writes use PasswordPepperConfig.CurrentVersion while stored rows select an exact historical key. |
internal/handler | HTTP adapters. Decodes requests, calls a service method, writes the JSON response, or sets cookies. Files are split by concern, including auth, sessions, OAuth, organizations, and admin operations. |
internal/migrations | Immutable embedded catalogs, standalone execution, database history, migration locks, baseline adoption, verification and artifact export. The root migration functions expose this independently of Auth. |
internal/sqlstore | The port repository interfaces implemented over database/sql. One _repo.go per aggregate, plus the DB wrapper described below. |
internal/routes | Canonical "METHOD /path" constants used by HTTP wiring and rate limit configuration. |
internal/sessioncontext | Internal app/admin credential context carried through services, refresh, 2FA, and OAuth. HTTP middleware validates the selector; it grants no permissions. |
internal/httproutes | The enabled route registry: pairs canonical patterns with handlers and each route's exact middleware chain. |
internal | Other unexported packages not part of the public API: schema (the embedded SQL statements + the statement splitter), testutil (hand-written fakes for the port interfaces), and low-level crypto/keyring/otp helpers. |
The dependency direction is strict: domain depends on nothing in this module; api depends on domain; port depends on api and domain; and service depends on api, domain, and port. The root package owns the public Services capability interfaces in services.go and wires concrete service implementations behind them. internal/sqlstore implements repository interfaces; internal/handler and middleware consume the service operations for HTTP. internal/httproutes composes internal/handler with the canonical patterns in internal/routes. Nothing in service imports internal/sqlstore or internal/handler; it touches net/http only for cookie constants (http.SameSite), never for request/response handling.
The Auth struct and New()
config.go owns the top-level Config, cloning, and NewConfig. The
config_*.go section files keep related types, options, defaults, and
validation together. config_defaults.go preserves defaulting order, while
config_validate.go collects section errors and checks cross-section rules.
New(in *Config) (*Auth, error) in auth.go is the composition root. Its setup helpers are grouped in wire_secrets.go, wire_database.go, wire_adapters.go, wire_services.go, and wire_http.go. In order, it:
- Clones and checks the config, derives keys, and builds the password hasher.
Its registry shares a password-work limiter across hashing, prefix-selected
verification, dummy checks and startup probes. The default cap is four active
operations per
Auth; callers can supply one limiter to multiple instances. Excess work returnspassword_work_busywithout waiting. - Opens (or adopts) the database connection based on
DatabaseConfigand wraps it ininternal/sqlstore.DB, which implementsport.TxManager. - Checks database connectivity and recorded schema compatibility for every database source before any services/workers start. It never runs migrations.
- Constructs the repositories, mailer, template provider, audit service, and dependent services in that order.
- Builds the HTTP adapters and middleware, then the enabled route registry with each route's specific chain.
- Starts maintenance and returns an
*AuthwithServices()for programmatic calls,Handler(pattern)for custom routers, and middleware accessors for application routes.Services()returns a value containing operation interfaces; disabled optional capabilities remain nil.
Auth.Mount(mux *http.ServeMux) registers the enabled route registry on the mux you give it. It does not add middleware of its own. Each handler already has its full chain baked in from step 5, and CORS preflight (OPTIONS) is registered per path when SecurityConfig.AllowedOrigins is non-empty. Handler(pattern) returns the enabled route's wrapped handler with a small adapter that sets the canonical Request.Pattern; it reports false for a disabled or unknown route. A custom router must populate PathValue for parameterized routes before invoking it.
Two ways to drive it
The service operations do not take HTTP requests or write HTTP responses. The supported operations are reachable two ways:
- Programmatically:
auth.Services().Auth.Register(ctx, api.RegisterInput{...}),auth.Services().Admin.BanUser(ctx, api.BanUserInput{...}), and so on. No cookies, no CSRF, no rate limiting. You get back plain Go values and are responsible for whatever transport you are building (a CLI, a gRPC service, a background job). Operations with multiple values aftercontext.Contexttake a named input fromapi(for example,api.LoginInput,api.TwoFactorVerifyInput, andapi.SetActiveOrgInput). Single-value operations keep their direct argument. See the integration contract for migration and lifecycle rules. - Over HTTP:
auth.Mount(mux)registers the same operations behind the full middleware chain, withinternal/handlertranslating cookies and JSON into service calls.
The middleware chain
Every entry in the internal/httproutes registry is a handler wrapped in the layers its route needs, applied in this relative order (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 abuse-prone and expensive routes, including
GET /auth/meand most admin reads. Some session-management routes are intentionally unwrapped; the handler wiring is the source of truth for an individual route. - CSRFToken and OriginCheck wrap browser mutations that use the usual request CSRF checks. OAuth initiation and callback use the OAuth state cookie flow instead; inspect the route registry for each endpoint's exact chain.
- Auth wraps anything that needs to know who is 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 checks the fixed
adminaccount role and required session assurance when app permissions are disabled. Enabled app administrative routes use exact permission middleware; catalog provisioning and organization oversight use protected-admin middleware. - 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 → RateLimit → 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 is 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. internal/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
$1, $2, ...placeholders;DB.Rebindleaves them as-is for PostgreSQL and rewrites them to?for MySQL/SQLite. - Transactions:
internal/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 is already inside one. A panic escapingfnrolls the transaction back via a deferred rollback before propagating, so it can never pin the connection. Password reset uses this boundary for token claim + guarded credential replacement + session deletion; change-password uses it for credential replacement + session deletion. Invite redemption (invite claim + account creation), OAuth registration (user + provider link), provider unlinking (provider-row lock + guard re-check + delete), organization member removal/role changes (counter upkeep + role-asserted write), and active-org selection (membership lock + session update) use the same boundary: the guard and the writes that follow it commit or roll back together. Their slow KDF work runs before the transaction so it does not hold database locks.
Admin bans hold the account-mutation guard through session revocation and audit recording. Enabling 2FA groups its flag update, optional revocation, and audit record in one transaction; unbanning and disabling 2FA group their state change with their audit record.
The internal authentication, session, admin, and 2FA constructors require a
port.TxManager as their first argument. They reject nil and typed-nil managers
with a panic because missing internal wiring is a programming error. There is
no setter or implicit autocommit fallback for these services. The composition
root supplies the same SQL transaction manager to all four; nested operations
join the transaction through the callback context. Repository unit tests
supply testutil.MockTxManager, while HTTP-only handler fixtures use an
explicit callback stub. Neither fixture establishes real database atomicity;
the SQL transaction tests cover that contract. Public goauth.New and
Auth.Services() signatures are unchanged.
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; log-only fallback in development when unconfigured; otherwise a custom mailer or none when no feature needs email | 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 | bounded in-memory | A distributed store (Redis, etc.) for multi-instance deployments. One method: Allow(ctx, key, Rate) (Result, error) |
audit.EventSink + audit.BatchDeliveryTimeBounder | an external delivery sink (webhook, Kafka, NATS) fed from the audit_outbox table by the dispatcher, with a finite batch-time bound used to validate the claim lease | Kafka, NATS, a webhook. Added via WithAuditSink |
port.Hasher | bcrypt (hasher package), cost 12; optional built-in hasher/argon2id; optional versioned HMAC layer | WithPasswordHashing groups Hasher, BcryptCost, Pepper, Concurrency and WorkLimiter; password policy stays in WithSecurity |
Repository interfaces are segmented, not just one flat contract per aggregate. port.UserRepository embeds the guarded writes (PasswordHashUpdater, AdminGuardStore) plus the name-only update and conditional email verification methods; port.SessionRepository composes SessionReader / SessionWriter / SessionRevoker / ActiveOrgSessionStore; port.OrgRepository composes OrgCRUD / OrgLimitCounters. SQL remains authoritative and there is no option to replace a credential repository; every consumer goes through internal/sqlstore. Optional WithRedis snapshot caches validate a current SQL version and bypass transactional reads. The narrow interfaces exist so each service depends only on the slice it uses — not as an extension point for non-SQL stores.
Testing structure
Unit tests live alongside the code they test (internal/service/*_test.go, internal/handler/*_test.go, ...) and use hand-written fakes for the port interfaces (see internal/testutil). The user fake stores and returns detached snapshots. MockTxManager restores participating repository rows after callback errors, cancellation, or panics; operations must use the callback context. It models callback rollback, and does not establish SQL isolation, constraints, savepoints, or durable audit guarantees. Handler callback stubs test HTTP behavior only.
Database contract and transaction tests use real, isolated databases through internal/testdb. GOAUTH_TEST_DRIVER selects SQLite (the default), PostgreSQL, or MySQL. Explicitly selecting an external backend requires GOAUTH_POSTGRES_DSN or GOAUTH_MYSQL_TEST_DSN and fails if the database is unavailable. These SQL fixtures verify concurrency, guarded claims, rollback, and cross-repository invariants against the canonical schemas.
The database CI matrix runs the root HTTP tests, integration tests, services,
SQL stores, and CLI against all three backends with race detection and shuffled
test order. Each normal run also repeats the concurrency and rollback suite
three times at CPU settings one and four. Manual dispatch can request a larger
stress count. The runner fails if required tests are absent, skipped, or do not
pass every required repetition. Results, shuffle seeds, and coverage are saved
under .test-results/{backend}/ and uploaded as CI artifacts.
Native PostgreSQL and MySQL tests force an actual deadlock across independent pools after role, permission, and audit writes. They require a typed engine deadlock, exactly one committed transaction, complete victim rollback, reusable connections, and a successful retry of the complete operation. MySQL also tests management from an older repeatable-read snapshot after session revocation, deletion, and removal of MFA assurance. These tests exercise database behavior; SQLite's serialized writes are not used as evidence for row-locking engines.
Authorization coverage tests independently inventory every exported method of the admin, invite, organization, and app-permissions services. Each method must have a tested catalog policy, a protected-admin policy, or a named explanation for being outside platform administration (for example, tenant self-service or trusted bootstrap). New methods and stale classifications fail the inventory. Direct-call tests check denial and session assurance for every administrative method, and inject a permission-reader failure to verify the exact catalog key without performing business work.
Route coverage compares every declared /admin and /admin/ pattern with an
independent policy table and checks registration and middleware order across all 64 HTTP
feature combinations. SQL-backed HTTP tests verify exact versus wrong grants,
removal and reinstallation, protected-admin bypass, non-delegable routes, and
MFA. Separate tests exercise unprivileged requests through the actual mounted
handlers. CI requires all six coverage tests in both full and repeated backend
runs; missing, skipped, or insufficient passing runs fail the job.
For custom application endpoints, Auth.Protect(pattern, handler) composes CORS, configured rate limiting, browser CSRF checks, authentication and current email-verification enforcement. Authorization wrappers passed inside it run after authentication. Its inner ServeMux supplies canonical pattern and path parameters on any HTTP router. See application routes.
Email-change capability
Services().Email exposes root-owned EmailOperations, with api/email.go
inputs/results and matching facade methods. It is nil when disabled. The HTTP
handlers and direct API share internal/service.EmailService and narrow SQL
capabilities. Lock order is user, initiating session, request. The separate
account slot retains abuse budgets across replacement without changing session
identity joins. Completion notices use the existing recovery queue/worker;
their fixed kind requires no additional payload or background service.
Redis integration
WithRedis wires disposable encrypted snapshots into SQL repositories and shared stores into rate limiting and 2FA notifications. Each cache read selects its exact identity/version in SQL, with current owning-user data where required. Mutations and locking reads stay in SQL. Immutable version keys make stale fills harmless; no invalidation worker, distributed lock or cross-store transaction is used. Session filtering/counts and token existence checks remain SQL-backed. See Redis.