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

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.

PackageResponsibility
domainCore types (User, Session, Organization, ...) and every AuthError. No dependencies on anything else in the module.
apiTransport-neutral inputs and results for programmatic operations, plus shared sort types, audit entries, and daily counts. Import as github.com/nazimdjebloun/go-auth/api.
portInterfaces the service layer depends on: Mailer, Hasher, TokenGenerator, TemplateProvider, OAuthProvider, TxManager, and one repository interface per aggregate (UserRepository, SessionRepository, OrgRepository, ...).
serviceBusiness logic, split by concern. Depends on api, domain, and port.
middlewareCross-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.
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 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.
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 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/handlerHTTP 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/migrationsImmutable embedded catalogs, standalone execution, database history, migration locks, baseline adoption, verification and artifact export. The root migration functions expose this independently of Auth.
internal/sqlstoreThe port repository interfaces implemented over database/sql. One _repo.go per aggregate, plus the DB wrapper described below.
internal/routesCanonical "METHOD /path" constants used by HTTP wiring and rate limit configuration.
internal/sessioncontextInternal app/admin credential context carried through services, refresh, 2FA, and OAuth. HTTP middleware validates the selector; it grants no permissions.
internal/httproutesThe enabled route registry: pairs canonical patterns with handlers and each route's exact middleware chain.
internalOther 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:

  1. 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 returns password_work_busy without waiting.
  2. Opens (or adopts) the database connection based on DatabaseConfig and wraps it in internal/sqlstore.DB, which implements port.TxManager.
  3. Checks database connectivity and recorded schema compatibility for every database source before any services/workers start. It never runs migrations.
  4. Constructs the repositories, mailer, template provider, audit service, and dependent services in that order.
  5. Builds the HTTP adapters and middleware, then the enabled route registry with each route's specific chain.
  6. Starts maintenance and returns an *Auth with Services() 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 after context.Context take a named input from api (for example, api.LoginInput, api.TwoFactorVerifyInput, and api.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, with internal/handler translating 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) → 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 abuse-prone and expensive routes, including GET /auth/me and 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.Context for the handler.
  • Admin checks the fixed admin account 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) → 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 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.Rebind leaves them as-is for PostgreSQL and rewrites them to ? for MySQL/SQLite.
  • Transactions: internal/sqlstore.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 is already inside one. A panic escaping fn rolls 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:

InterfaceBuilt-in implementationReplace it for
port.MailerSMTP via WithEmail; log-only fallback in development when unconfigured; otherwise a custom mailer or none when no feature needs emailResend, 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.Storebounded in-memoryA distributed store (Redis, etc.) for multi-instance deployments. One method: Allow(ctx, key, Rate) (Result, error)
audit.EventSink + audit.BatchDeliveryTimeBounderan 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 leaseKafka, NATS, a webhook. Added via WithAuditSink
port.Hasherbcrypt (hasher package), cost 12; optional built-in hasher/argon2id; optional versioned HMAC layerWithPasswordHashing 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.

On this page