authx

package module
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 39 Imported by: 0

README

go-auth-x

Go Authentication, eXtended — a self-contained, batteries-included authentication library for Go web apps. Embed multi-method auth directly in your service with no separate identity provider to run: one signed, HttpOnly session cookie; the browser never sees a token.

Go Reference Go Report Card Release

import authx "github.com/alex-savin/go-auth-x"

Extracted from a production trading platform's backend-for-frontend (BFF). Passwords, passkeys (incl. cross-device QR), email magic-links, OIDC, social login, and 2FA (TOTP + passkey + step-up) — all behind a single completeLogin funnel, plus an optional enterprise directory layer (groups, API keys, LDAP, SCIM).


Table of contents


Why go-auth-x

Most Go auth options force a choice between two extremes:

  1. Run a separate identity provider (Keycloak, ORY, Auth0). Powerful, but now you operate another service, your passkeys live on a different origin, and simple apps inherit a lot of moving parts.
  2. Hand-roll it. A session cookie here, a bcrypt call there, a WebAuthn ceremony you hope you got right — and the account-linking edge cases that cause real takeovers.

go-auth-x is the middle path: a library you embed. It runs in your process, authenticates users directly with whatever methods you enable, and hands your app one signed session cookie. Because it's same-origin, passkeys (including the phone-scanned QR / cross-device flow) just work — no IdP hand-off, no "double login page."

Use go-auth-x when you want full-featured auth inside one Go app without operating an IdP. Use a dedicated IdP when you need centralized SSO across many separate services.


Feature overview

Authentication methods

  • 🔑 Passwords — bcrypt (cost 12), NIST-style policy, constant-time anti-enumeration.
  • 🟢 Passkeys / WebAuthn — discoverable (usernameless) login, cross-device QR (FIDO2 hybrid), clone detection, self-service enrollment & removal.
  • ✉️ Email magic-links + OTP — passwordless sign-in by single-use link or 6-digit numeric code (email-scoped, salted-at-rest, attempt-capped), plus email verification + password reset.
  • 🌐 OIDC client — Authorization Code + PKCE; consume any OIDC provider (Keycloak, Auth0, …).
  • 👥 Social login — Google, Apple & Microsoft/Entra (OIDC), GitHub, Facebook & Discord (REST/Graph), plus any OIDC provider via Config.SocialOIDC; verified-email only. Self-service unlink.
  • 🔐 Two-factor (2FA) — TOTP authenticator apps + single-use recovery codes; stdlib, no dependency.
  • 🔎 Breached-password check — optional HIBP k-anonymity screening (BreachChecker), fail-open.

Session & transport

  • 🍪 BFF sessions — one HMAC-signed (HS256) HttpOnly cookie; the SPA never handles tokens.
  • 🔄 Key rotationPreviousSessionSecrets verify-list rotates SESSION_SECRET without mass logout.
  • 📵 Revocable sessions (optional) — a pluggable SessionStore adds list/revoke-device, sign-out-everywhere, and ban-revokes-all over the stateless default.
  • 🛡 CSRF — double-submit token + optional trusted-origins allow-list (defense-in-depth).
  • 🧩 Framework-agnostic — mount as an http.Handler; net/http middleware for chi/echo/stdlib.

Account self-service

  • 👤 Change password, verified change-email, delete account (GDPR cascade), passkey enroll/rename/remove.

Directory (optional, enterprise tier)

  • 🗂 Groups + per-group access control middleware.
  • 🔐 API keys (Bearer) + an admin REST API: create/disable/ban/delete users, set passwords, impersonate, and list/revoke a user's sessions.
  • 🏢 LDAP sync and a minimal SCIM 2.0 provisioning server.
  • 🏬 Organizations (multi-tenant orgs) — per-org roles, an active-org session claim + switching, email invites (listable/revocable records), RequireOrgHTTP middleware, admin org CRUD, and org-scoped resources: per-org groups + org-bound API keys unlocking per-customer SCIM.

Operational

  • 🚦 Rate limiting + soft lockout (429 + Retry-After, IPv6 /64 keying), trusted-proxy-aware client IP.
  • 🧪 Reference GORM and in-memory stores; swap in your own via small interfaces.

Install

go get github.com/alex-savin/go-auth-x

Requires Go 1.26+. Sub-packages:

Import Purpose
github.com/alex-savin/go-auth-x The auth engine (authx)
github.com/alex-savin/go-auth-x/store/gormstore Reference GORM CredentialStore + DirectoryStore
github.com/alex-savin/go-auth-x/store/memory Zero-dependency in-memory store (tests/demos)
github.com/alex-savin/go-auth-x/mailer SMTP (STARTTLS) Mailer
github.com/alex-savin/go-auth-x/ldapsync LDAP/AD → directory sync
github.com/alex-savin/go-auth-x/scim SCIM 2.0 provisioning server

Quick start

The library is pure net/http — no framework dependency. Mount Handler() in any mux (net/http, chi, echo, …) and gate your own routes with the provided middleware.

package main

import (
	"context"
	"log"
	"net/http"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"

	authx "github.com/alex-savin/go-auth-x"
	"github.com/alex-savin/go-auth-x/mailer"
	"github.com/alex-savin/go-auth-x/scim"
	"github.com/alex-savin/go-auth-x/store/gormstore"
)

func main() {
	db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
	store, _ := gormstore.New(db) // migrates the authx_* tables

	cfg := authx.ConfigFromEnv() // SESSION_SECRET (>=32B), APP_URL, OWNER_EMAIL, OIDC_*, GOOGLE_*, ...
	authn, err := authx.New(context.Background(), cfg)
	if err != nil {
		log.Fatal(err)
	}
	authn.SetCredentialStore(store)         // persistence
	authn.SetDirectoryStore(store)          // optional: groups / API keys / admin API
	authn.SetTwoFactorStore(store)          // optional: TOTP 2FA + recovery codes
	authn.SetSessionStore(store)            // optional: revocable sessions (list/revoke devices, sign-out-everywhere)
	authn.SetBreachChecker(authx.NewHIBPBreachChecker()) // optional: HIBP password-breach screening (or HIBP_BREACH_CHECK=true)
	authn.SetMailer(mailer.FromEnv())       // optional: enables magic-link / email-OTP / verify / reset
	authn.SetAuthorizer(store.Authorizer()) // safe identity upsert (wrap to add provisioning)
	authn.SetLocalEnabled(true)             // turn on password + passkey + email

	mux := http.NewServeMux()
	mux.Handle("/auth/", authn.Handler()) // /auth/* (JSON + ceremony endpoints; you own the UI)
	mux.Handle("/scim/v2/", http.StripPrefix("/scim/v2", // optional SCIM provisioning
		scim.NewServer(store, func(t string) bool { return authn.ValidateAPIKeyScope(t, "scim") }).Handler()))

	// your app, gated by session + CSRF; restrict sensitive routes by group:
	app := http.NewServeMux()
	app.Handle("/admin", authn.RequireGroupsHTTP("admins")(http.HandlerFunc(adminHandler)))
	mux.Handle("/", authn.GateHTTP(authn.CSRFHTTP(app)))

	log.Fatal(http.ListenAndServe(":8080", mux))
}

// inside any gated handler:
//   sub, email, ok := authx.SessionFromRequest(r)
//   groups := authx.GroupsFromRequest(r)

You provide the login UI. The library serves JSON + the WebAuthn/OIDC ceremony endpoints under /auth/*; your app renders the login/register pages and calls them (see HTTP endpoints).


Architecture

go-auth-x is a backend-for-frontend. The single-page app talks only to your backend; the backend holds all secrets and issues one signed cookie.

flowchart LR
    SPA["Browser / SPA<br/>one signed cookie · never a token"]

    subgraph app["Your Go app — go-auth-x embedded"]
      direction TB
      MW["Middleware<br/>Gate · CSRF · RequireGroups"]
      PW["password"]
      PK["passkey / QR"]
      MK["magic-link"]
      OI["OIDC"]
      SO["social"]
      FUN(["completeLogin — the one funnel<br/>run Authorizer · add groups · mint cookie + CSRF"])
      MW --> PW & PK & MK & OI & SO
      PW & PK & MK & OI & SO --> FUN
    end

    subgraph seams["Seams — small interfaces you implement"]
      direction TB
      AZ["Authorizer<br/>post-login hook"]
      CS["CredentialStore"]
      DS["DirectoryStore<br/>optional · groups · API keys · admin"]
      MR["Mailer"]
      RL["RateLimiter"]
    end

    subgraph ext["External"]
      direction TB
      IDP["OIDC · Google · Apple · Microsoft/Entra<br/>GitHub · Facebook · Discord · any OIDC"]
      DB[("Postgres — gormstore<br/>or in-memory")]
      SMTP["SMTP"]
      LDAP["LDAP · AD · SCIM push"]
    end

    SPA <-->|"session cookie · HS256"| MW
    OI <-->|"auth code + PKCE"| IDP
    SO <--> IDP
    FUN --> AZ & CS & DS
    MK --> MR
    MW -.->|"rate-limit /auth"| RL
    CS --> DB
    DS --> DB
    DS <-->|"sync / provision"| LDAP
    MR --> SMTP

The funnel. Password, passkey, magic-link, OIDC, and social logins all end in one completeLogin — which runs the Authorizer, enriches the session with the user's groups, mints the cookie, and issues a CSRF token. No method can mint a session that skips your provisioning or the disabled/verified gates.

The seams (all small interfaces, so you control storage and policy):

Interface Responsibility Reference impl
CredentialStore persist users, passwords, passkeys, tokens, OAuth identities gormstore, memory
Authorizer post-login hook: identity upsert (+ your provisioning) store.Authorizer()
Mailer send auth emails mailer (SMTP)
DirectoryStore (optional) groups, membership, API keys, admin user mgmt gormstore, memory

Configuration

authx.ConfigFromEnv() reads the environment; or build authx.Config{} yourself. Local methods are turned on with SetLocalEnabled(true); email methods activate when a Mailer is set; each social provider activates when its client id + secret are present.

Env var Config field Notes
SESSION_SECRET SessionSecret Required, ≥ 32 bytes. HMAC key for the session/flow/CSRF cookies.
SESSION_SECRET_PREVIOUS PreviousSessionSecrets Comma-separated retired keys, accepted for verification only, so SESSION_SECRET rotates without mass logout. Each ≥ 32 bytes; drop after one TTL window.
APP_URL AppURL Public origin, e.g. https://app.example.com. Used for email links + redirects.
TRUSTED_ORIGINS TrustedOrigins Comma-separated extra origins allowed on state-changing requests (defense-in-depth atop the CSRF token; AppURL is always allowed). Fails open when absent.
HIBP_BREACH_CHECK BreachCheckHIBP true wires the default HIBP k-anonymity password-breach checker (fail-open). Or call SetBreachChecker.
OWNER_EMAIL OwnerEmail Exempt from hard lockout; gates the admin API via session.
BRAND_NAME BrandName Product name shown in auth emails and the default WebAuthn RP display name (default go-auth-x).
COOKIE_SECURE CookieSecure Force the Secure flag (also auto-on under TLS / X-Forwarded-Proto: https).
OIDC_ISSUER Issuer Enables the OIDC client (with OIDC_CLIENT_ID).
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET ClientID / ClientSecret Confidential OIDC client.
OIDC_REDIRECT_URL RedirectURL e.g. https://app.example.com/auth/callback.
OIDC_ALLOWED_GROUPS AllowedGroups Comma-sep allow-list; empty = any authenticated user.
OIDC_ASSUME_VERIFIED OIDCAssumeVerified Trust the issuer's email when the id_token omits email_verified (single trusted IdP only). Default off → require the claim.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET GoogleClientID / …Secret Enables Google login.
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET GitHubClientID / …Secret Enables GitHub login.
FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET FacebookClientID / …Secret Enables Facebook login.
APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY AppleClientID / …TeamID / …KeyID / …PrivateKey Sign in with Apple (Services ID, Team ID, Key ID, .p8 PEM). Requires HTTPS.
MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET / MICROSOFT_TENANT MicrosoftClientID / …Secret / …Tenant Microsoft / Entra ID login. Pin MICROSOFT_TENANT to a single tenant GUID / verified domain to enable sign-in — the default common (and organizations/consumers) is multi-tenant, where the token email is attacker-controllable (nOAuth) and therefore not trusted, so those sign-ins are refused.
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET DiscordClientID / …Secret Enables Discord login (verified email required).
MICROSOFT_STRICT_EMAIL_VERIFIED MicrosoftStrictEmailVerified For a pinned single tenant, also require an explicit email_verified rather than trusting the tenant-owned address (default off). Has no effect in multi-tenant mode, where the email is never trusted.
— (programmatic) SocialOIDC []SocialOIDCProvider Register any OIDC IdP (GitLab, Okta, Auth0, Keycloak, …) as a social login.
TRUSTED_PROXIES — (authx.TrustedProxies()) CSV of proxy CIDRs; default = private ranges + loopback.
WEBAUTHN_RPID / WEBAUTHN_RP_NAME Override the passkey relying-party id/name (default: app host).
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS SMTP_FROM — (mailer.FromEnv()) STARTTLS sender.

Boot fails closed if auth is active and SESSION_SECRET is shorter than 32 bytes.


Authentication methods

Passwords

POST /auth/password/signup → creates an unverified user and emails a verification link. POST /auth/password/login → verifies bcrypt, requires a verified email, mints the session. NIST-style policy (length + breach/email-localpart checks), constant-cost dummy-hash on unknown users to defeat enumeration, and a transparent rehash-on-login path that upgrades stored hashes when the bcrypt cost is raised (algorithm recorded via a stored tag).

Passkeys / WebAuthn (incl. cross-device QR)

Same-origin WebAuthn — no IdP hop. Registration requires a discoverable (resident) credential and does not pin authenticatorAttachment, so an enrolled passkey works passwordless and cross-device. Login is discoverable (usernameless), which is exactly what makes the browser offer "use a phone or tablet" → a QR code (FIDO2 hybrid transport). Sign-count clone detection rejects copied authenticators. Challenges live in a short-lived signed cookie; the relying-party id is fixed config.

POST /auth/email/request emails a one-time sign-in link; GET /auth/email/login redeems it (and marks the email verified). Same machinery powers email verification and password reset. All tokens are 256-bit, sha256-at-rest, single-use (atomic redemption), per-purpose TTL, with always-200 anti-enumeration.

OIDC

GET /auth/login runs Authorization Code + PKCE (S256) with state + nonce; GET /auth/callback verifies the id_token, enforces OIDC_ALLOWED_GROUPS, and funnels into completeLogin. state/nonce are compared in constant time (OAuth 2.0 Security BCP / RFC 9700).

Social (Google, GitHub, Facebook, Apple, Microsoft, Discord + any OIDC)

GET /auth/social/{provider}/login. Google, Apple & Microsoft/Entra use OIDC (verified-email id_token + nonce + azp); GitHub uses REST (/user + /user/emails, primary+verified); Facebook uses the Graph API (/me, with an appsecret_proof); Discord uses REST (/users/@me, verified email). Identities key on (provider, subject); a new identity links to a verified-email user, reclaims an unverified squatter, or creates one (see account-linking invariant).

Any OIDC provider — register one programmatically and it mounts at /auth/social/<name>/…:

cfg.SocialOIDC = []authx.SocialOIDCProvider{{
    Name: "gitlab", Issuer: "https://gitlab.com",
    ClientID: id, ClientSecret: secret, // Scopes default to openid, email, profile
}}

This covers GitLab, Okta, Auth0, Keycloak, and the like with the same PKCE + nonce + azp + email_verified path as Google. Set AssumeVerified per provider to control whether a token that omits email_verified is trusted (only for a single, trusted issuer that owns its addresses). GET /auth/config lists every enabled provider in socialProviders so the SPA can render the right buttons.

Microsoft / Entra — pin your tenant. The multi-tenant endpoints (common, organizations, consumers, or an unset MICROSOFT_TENANT) accept id_tokens from any Entra tenant, so the token's email is attacker-controllable (the nOAuth class). go-auth-x therefore does not trust the email in multi-tenant mode — because Entra omits email_verified, those sign-ins are refused. Set MICROSOFT_TENANT to a single tenant GUID or a verified custom domain to enable Microsoft sign-in; only then is the tenant-owned email trusted for account linking.

Sign in with Apple needs four env values (APPLE_CLIENT_ID = Services ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY = the .p8 PEM) — the library signs Apple's ES256 client-secret JWT for you and regenerates it per exchange. Because Apple returns its callback via form_post (a cross-site POST), the login flow cookie is set SameSite=None, so Apple requires HTTPS and the name is delivered only on the user's first authorization.

Two-factor (TOTP + recovery codes)

Wire the optional TwoFactorStore with SetTwoFactorStore(store) (reference impls in gormstore + memory). TOTP is implemented with the standard library only — no dependency (RFC 4226/6238, HMAC-SHA1 / 6 digits / 30s, ±1-step skew), verified against the published RFC vectors.

  • EnrollPOST /auth/2fa/totp/begin (session-gated) returns the otpauth:// URI + base32 secret (you render the QR — the library ships no image dependency); POST /auth/2fa/totp/confirm validates a code, enables it, and returns single-use recovery codes once.
  • Enforce — when a user has TOTP enabled, a first-factor login no longer mints the session: it sets a short-lived signed 2fa_pending cookie and the response signals a second factor is needed ({ "twoFactorRequired": true } for the JSON methods; a redirect with ?mfa=required for social/magic-link — poll GET /auth/2fa/pending). POST /auth/2fa/verify with a TOTP or a recovery code finishes the login.
  • Security — codes are constant-time compared; a used time-step is remembered to reject replay; recovery codes are sha256-at-rest + single-use; POST /auth/2fa/disable requires a current code; /auth/2fa/verify is rate-limited.
  • Passkey as a second factor — a 2FA-halted login can instead complete the second step with a registered passkey (POST /auth/2fa/webauthn/begin + /finish).
  • Step-up / re-auth — wrap sensitive routes with authn.RequireStepUpHTTP(maxAge); a stale/absent step-up returns 403 {"error":"reauth_required"}, and POST /auth/reauth (password or a 2FA code) sets a short-lived step-up cookie so the action can proceed.

HTTP endpoints

Mounted under /auth by Handler():

Method Path Purpose Auth
GET /auth/login · /auth/callback OIDC start / callback public
GET /auth/logout · /auth/me · /auth/config logout / session info / enabled methods public
POST /auth/password/signup · /auth/password/login password signup / login public
POST /auth/password/reset/request · /auth/password/reset/confirm password reset public
POST /auth/email/request request a magic-link public
GET /auth/email/login · /auth/email/verify redeem magic-link / verify email token
POST /auth/email-otp/send · /auth/email-otp/verify request / redeem a 6-digit sign-in code public
GET /auth/email/change confirm a new email (change-email flow) token
POST /auth/webauthn/login/begin · /auth/webauthn/login/finish passkey sign-in (discoverable / QR) public
POST /auth/webauthn/register/begin · /auth/webauthn/register/finish enroll a passkey session
GET /auth/social/{provider}/login start social login (Google / GitHub / Facebook / Apple / Microsoft / Discord / any OIDC) public
GET · POST /auth/social/{provider}/callback social callback (POST is Apple's form_post) public
POST /auth/2fa/totp/begin · /auth/2fa/totp/confirm · /auth/2fa/disable enroll / confirm / disable TOTP session
POST · GET /auth/2fa/verify · /auth/2fa/pending finish a 2FA-challenged login / poll state 2fa-pending cookie
POST /auth/2fa/webauthn/begin · /auth/2fa/webauthn/finish passkey as the second factor 2fa-pending cookie
POST /auth/reauth step-up re-auth (password or a 2FA code) session + CSRF
GET /auth/api/account account + passkeys + linked providers session
POST /auth/api/account/password set/change password session + CSRF
POST /auth/api/account/email start a verified email change session + step-up + CSRF
DELETE /auth/api/account delete the account (irreversible) session + step-up + CSRF
POST · DELETE /auth/api/passkeys/{id} rename / remove a passkey session + CSRF
DELETE /auth/api/identities/{provider} unlink a social/OIDC identity session + CSRF
GET · DELETE /auth/api/sessions · /auth/api/sessions/{sid} list / revoke your sessions session (+ CSRF)
POST /auth/api/sessions/revoke-others sign out your other devices session + CSRF
POST /auth/api/stop-impersonating end an admin impersonation session session + CSRF
GET /auth/orgs my org memberships + the active org session
POST /auth/org/switch switch (or clear) the active org session + CSRF
GET /auth/org/members list the active org's members session (member)
POST · DELETE /auth/org/members/{userId} set a member's role / remove (or leave) session (owner/admin) + CSRF
POST · GET /auth/org/invites email a single-use org invite / list pending session (owner/admin) (+ CSRF)
DELETE /auth/org/invites/{id} revoke a pending invite session (owner/admin) + CSRF
GET /auth/org/invite/accept redeem an invite from the emailed link session + token

The account, session-management, unlink, and delete endpoints require a SessionStore only for the device-list/revoke features; everything else works statelessly. Step-up-gated actions expect a fresh POST /auth/reauth (see Two-factor).


Directory layer

Optional. Wire SetDirectoryStore(store) to enable groups, access control, API keys, the admin REST API, and the LDAP/SCIM integrations.

Groups & access control

Groups are first-class and surfaced in the session at login. Gate routes by group membership — for sessions and API keys:

// gate by group membership — works for both session users and API keys:
mux.Handle("/reports", authn.RequireGroupsHTTP("analysts", "admins")(reportsHandler))
API keys

axk_-prefixed, sha256-at-rest, with optional expiry, groups, and scopes. Scopes are deny-by-default (least privilege): an empty list grants nothing — use ["admin"], ["scim"], or ["*"] for a root key. The Authorization: Bearer <key> header is validated via authn.ValidateAPIKey(raw) / authn.ValidateAPIKeyScope(raw, "scim"). CSRF is correctly skipped for Bearer requests (not cookie-based). The raw key is shown once at creation.

Admin REST API (/auth/admin)

Gated by an owner session (OWNER_EMAIL) or a valid API key carrying the admin scope:

Method Path Purpose
GET / POST /auth/admin/groups list / create groups
DELETE /auth/admin/groups/{id} delete a group
GET /auth/admin/groups/{id}/members list members
POST / DELETE /auth/admin/groups/{id}/members/{userId} add / remove a member
GET / POST /auth/admin/users list / create users
POST /auth/admin/users/{id}/disabled disable / enable a user
POST /auth/admin/users/{id}/ban set / clear a time-boxed ban (reason + optional expiry)
POST /auth/admin/users/{id}/password set / reset a user's password (policy-checked)
DELETE /auth/admin/users/{id} hard-delete a user (cascade)
POST /auth/admin/users/{id}/impersonate start impersonating a user
GET / POST /auth/admin/users/{id}/sessions · …/sessions/revoke list / revoke a user's sessions
GET / POST /auth/admin/apikeys list / create keys (create returns the raw key once)
DELETE /auth/admin/apikeys/{id} revoke a key
LDAP sync

One-way scheduled pull of users + groups + membership from LDAP/Active Directory:

syncer := ldapsync.New(ldapsync.Config{
	URL: "ldaps://ldap.example.com", BindDN: "...", BindPassword: "...",
	UserBaseDN: "ou=people,dc=example,dc=com", GroupBaseDN: "ou=groups,dc=example,dc=com",
	// AD: AttrUID:"sAMAccountName", GroupFilter:"(objectClass=group)", AttrGroupMember:"member"
}, store)
result, err := syncer.Sync() // run on a schedule
SCIM 2.0

A mountable provisioning server so an upstream IdP (Okta, Entra/Azure AD, JumpCloud) can push and deprovision Users + Groups. Supports create / read / list / PATCH deprovision (active=false) / PUT replace / delete, a /Bulk endpoint, filters (eq/ne/co/sw/ew/gt/ge/lt/le/pr with and/or/not + parens + valuePath emails[type eq "work"]), sorting (sortBy/sortOrder), pagination (startIndex/count), and ETags (If-Match / If-None-Match). Resources carry meta.created/meta.lastModified, and discovery is complete: ServiceProviderConfig / ResourceTypes / full /Schemas documents (+ /Schemas/{id}). Bearer-auth via ValidateAPIKey.

mux.Handle("/scim/v2/", http.StripPrefix("/scim/v2",
	scim.NewServer(store, func(t string) bool { _, ok := authn.ValidateAPIKey(t); return ok }).Handler()))
Organizations (multi-tenant orgs)

Optional, via its own store: SetOrgStore(store) (both reference stores implement it; also requires a CredentialStore — memberships are keyed by its user IDs). Orgs are a layer above authentication: users stay global (one account, many orgs, a different role in each — reserved owner/admin/member plus your own tokens), so the one-user-per-email invariant and the safe account-linking rule are untouched. If you need fully isolated user pools instead (realm-style tenancy), run one Authenticator + store per tenant and route by host — that composes today and needs no schema.

  • Active org in the session — a sole membership is auto-activated at login; with several, the app shows a picker and calls POST /auth/org/switch (live membership check, cookie re-minted with its remaining lifetime — /auth/me returns org, orgRole, and the full orgs list).
  • Enforcement is liveRequireOrgHTTP(roles...) re-verifies membership + role against the store on every request, so off-boarding takes effect immediately, not at cookie expiry. It gates who is acting in which org; row-level isolation of your app's data remains your responsibility.
  • InvitesPOST /auth/org/invites emails a single-use, hashed-at-rest token whose org + role bind to a first-class record: pending invites are listable (GET) and revocable (DELETE /{id}), and re-inviting an address replaces its pending invite. Accepting requires signing in with the invited address (the token proves the mailbox — so it also marks the email verified); nothing in the link can be tampered with, and the wrong account can't burn the token. Owners/admins invite; only owners may grant owner.
  • Member management — members list the org; owners/admins set roles and remove members; anyone may leave. A serialized last-owner guard refuses demoting/removing the final owner (the admin API bypasses it as the recovery path).
  • Org-scoped groups & API keys (via the OrgDirectoryStore upgrade interface — both reference stores implement it) — groups and keys bound to one org, invisible to every global surface: two orgs both own "engineering" (never in the session cookie; gate with RequireOrgGroupsHTTP, checked live), and an org-bound key is refused by adminGuard/ValidateAPIKeyScope outright, passing only ValidateOrgAPIKeyScope for its own org.
  • Per-customer SCIMNewOrgScopedDirectory(store, store, org.ID) presents ONE org as a complete DirectoryStore for scim.NewServer, so each customer's IdP provisions only its own org: subjects are namespaced per org, an existing outside account is never adopted by an asserted email (409 — use the invite flow), and deprovisioning removes the user from the org, never the global account (owners can't be deprovisioned via SCIM at all).
// per-customer SCIM mount: org-bound key + org-scoped directory view
view, _ := authx.NewOrgScopedDirectory(store, store, org.ID)
mux.Handle("/scim/"+org.Slug+"/v2/", http.StripPrefix("/scim/"+org.Slug+"/v2",
	scim.NewServer(view, func(t string) bool { return authn.ValidateOrgAPIKeyScope(t, "scim", org.ID) }).Handler()))
authn.SetOrgStore(store) // gormstore + memory both implement authx.OrgStore

// gate an org-scoped area to live members (any role), or specific roles:
mux.Handle("/app/", authn.GateHTTP(authn.RequireOrgHTTP()(appHandler)))
mux.Handle("/app/billing", authn.GateHTTP(authn.RequireOrgHTTP(authx.OrgRoleOwner, "billing")(billingHandler)))

Storage

go-auth-x ships two reference stores; both implement CredentialStore and DirectoryStore (plus the optional TwoFactorStore, SessionStore, and OrgStore):

  • gormstore — Postgres/SQLite via GORM. New(db) migrates self-contained authx_* tables (so it won't collide with yours) and adds a unique-email index. Ships the safe verified-email linking rule and a reference Authorizer.
  • memory — zero-dependency, in-process. Great for tests, demos, and single-process apps; state is lost on restart and not shared across replicas.

Bring your own: implement CredentialStore (and optionally DirectoryStore) over your database. The interfaces are deliberately small and return storage-agnostic DTOs, so authx never imports your ORM. Compile-time conformance: var _ authx.CredentialStore = (*MyStore)(nil).

Entity IDs are opaque strings. AuthUser.ID, Group.ID, APIKeyInfo.ID, and every id parameter are opaque — authx never parses one or does arithmetic on it, it only hands it back to you — so a store keyed by uuid / ULID / KSUID returns its native IDs verbatim. The reference GORM store keeps uint primary keys and converts to/from decimal strings at its boundary (no DB migration). Treat an unknown/unparseable id as a normal miss (ErrNoUser / ErrNoGroup / a no-op delete); "" is the reserved "no user" sentinel.


Security model

  • Sessions — HS256-signed cookie; alg-confusion pinned (rejects alg:none/non-HMAC); exp/iat enforced; rotated on every login. Remember-me extends the TTL (12 h → 30 d).
  • CSRF — double-submit token (X-CSRF-Tokensweep_csrf cookie), constant-time compare, enforced on non-GET /api + /auth; ensured per-session in the gate; skipped for Bearer (API-key) requests.
  • Passwords — bcrypt cost 12; NIST-style policy; constant-cost dummy-hash anti-enumeration; rehash tag.
  • Tokens (magic-link / verify / reset) — 256-bit crypto/rand, sha256-at-rest, single-use (atomic), per-purpose TTL, always-200 anti-enumeration.
  • PasskeysrpID/origin taken from the configured app origin (override with WEBAUTHN_RPID), not the request Host (so it can't be spoofed); signed challenge cookie, clone detection.
  • OAuth/OIDC — Authorization Code + PKCE (S256) + state + nonce, constant-time compares; email trusted only when email_verified is set (or OIDC_ASSUME_VERIFIED for a single trusted IdP).
  • Rate limiting — sliding-window per-IP + soft per-account lockout (durable counts; recovery paths stay open; owner exempt). Pluggable: supply a shared-store backend via SetRateLimiters for multi-replica deployments.
  • Trusted proxies — a built-in X-Forwarded-For walk trusting only TrustedProxies() ranges, so the client IP (and per-IP limits) can't be spoofed.
Account-linking invariant

The single most important rule, shipped in both reference stores and covered by tests: a credential or social identity links to an existing user only on a provider-verified or redemption-proven email. An unverified, non-bootstrap "squatter" row is never adopted. When a verified login (OIDC/social/redeemed) lands on a squatter, it reclaims the email — deleting the squatter and its credentials, then provisioning a clean verified user; an unverified collision is refused (ErrEmailConflict). This prevents the classic cross-provider account takeover (attacker pre-registers victim@x; the real victim must never inherit the attacker's credentials).


Framework support

Pure net/http — zero framework dependency (no Gin, no router). Use it anywhere:

  • Handler() — mount /auth/* in any mux (net/http, chi, echo, …).
  • GateHTTP / CSRFHTTP — middleware to gate + CSRF-protect your own routes.
  • RequireGroupsHTTP(...) — per-group access control.
  • SessionFromRequest(r) / GroupsFromRequest(r) — read the authenticated principal.
  • ValidateAPIKey / ValidateAPIKeyScope — Bearer API-key auth for server-to-server + SCIM.

The SCIM server is a standalone http.Handler too. Client IP is extracted with a built-in trusted-proxy walk (TrustedProxies() / TRUSTED_PROXIES), so X-Forwarded-For can't be spoofed.


Testing

go test ./...

Covers: session mint/parse + tamper rejection, bcrypt, the account-linking matrix (squatter-reclaimed by a verified login / unverified-collision refused / bootstrap-adopted / verified-linked), OAuth-identity round-trip, the net/http adapter, the directory store + API-key scopes + RequireGroupsHTTP allow/deny, and the SCIM user lifecycle (provision → filter → PUT → deprovision). The reference stores carry compile-time interface assertions.


Project layout

go-auth-x/
├── *.go                  authx engine: session, password, webauthn, magic-link, oidc, social,
│                         csrf, ratelimit, middleware, http adapter, directory, admin
├── store/
│   ├── gormstore/        reference GORM CredentialStore + DirectoryStore (+ linking rule)
│   └── memory/           zero-dependency in-memory store
├── mailer/               SMTP (STARTTLS) Mailer
├── ldapsync/             LDAP/AD → directory sync
├── scim/                 SCIM 2.0 provisioning server
├── LICENSE               MIT
└── README.md

Roadmap

See ROADMAP.md for the full list and CHANGELOG.md for details.

  • v0.8.0 (latest): opaque entity IDs (issue #2) — every public entity ID is now an opaque string across the store interfaces, so uuid/ULID/KSUID-keyed stores pass their IDs straight through. Breaking, but no DB migration and no change for consumers of the reference stores (they keep uint PKs and convert at the boundary); custom store implementers update signatures to the string id types.
  • v0.7.0: organizations — org-scoped resources — first-class listable/revocable invite records, per-org groups + API keys (via the optional OrgDirectoryStore), and per-customer SCIM (NewOrgScopedDirectory) with a namespaced, no-cross-tenant-rebind trust boundary.
  • v0.6.0: organizations — core — an optional OrgStore (users stay global; per-org roles), an active-org session claim + POST /auth/org/switch, RequireOrgHTTP (live re-verification), email invites, self-service member management (last-owner guard), and admin org verbs. Additive.
  • v0.5.0: self-service, session management, admin, and hardening — HIBP breach check, SESSION_SECRET rotation, email OTP, an optional SessionStore (server-side revocation / device list / sign-out-everywhere), self-service delete-account + verified change-email + OAuth unlink + passkey rename, and admin verbs (create / set-password / ban / hard-delete / impersonate). Plus two audit passes: Microsoft multi-tenant email no longer trusted (nOAuth), social-only deployments still gate, verify fails closed on a weak key, SCIM create-uniqueness, and more. Breaking: several new store-interface methods + the optional SessionStore (see CHANGELOG).
  • v0.4.1: /auth/me fix — reports authEnabled and reads the session in local-only mode (local methods on, OIDC off), so frontends render the signed-in state and logout control.
  • v0.4.0: a security-hardening pass from a full audit — fail-closed session signing, the account-linking invariant enforced on the OIDC callback, Apple form_post CSRF fix, 2FA/reauth throttling, an atomic TOTP replay guard, request body-size + SCIM filter-depth limits, plus LDAP paging, email/SMTP hardening, Config.BrandName, and bcrypt rehash-on-login. Breaking: TwoFactorStore gains ClaimTOTPStep.
  • v0.3.0: Microsoft/Entra + Discord + any-OIDC social (Config.SocialOIDC); SCIM pagination (startIndex/count), meta.created/lastModified, and full /Schemas documents.
  • v0.2.0: 2FA (TOTP + recovery codes, passkey-as-2FA, step-up re-auth); Apple + Facebook social; a much richer SCIM server (full filter grammar + valuePath, sorting, strong ETags, Location/uniqueness/PATCH validation); and an RFC-compliance hardening pass.
  • v0.1.x: pure net/http; OIDC, password, passkey (+ QR), magic-link, Google/GitHub social; GORM + in-memory stores; SMTP mailer; groups + per-group access control; API keys with deny-by-default scopes + admin REST API; LDAP sync; SCIM 2.0; the squatter-reclaim path.

Contributing

Issues and PRs welcome. Please run go test ./... and go vet ./... before submitting, and keep the boundaries intact: the authx package must stay storage-agnostic (no specific ORM) and framework-free (net/http only — no web framework imports). New auth methods should funnel through completeLogin and respect the account-linking invariant.


License

MIT © 2026 Alex Savin.

Documentation

Overview

Package authx is a self-contained authentication library for Go web apps: passwords, passkeys (WebAuthn), email magic-links, OIDC, social login, and 2FA behind one signed HttpOnly session cookie — the SPA never handles tokens. It runs a backend-for-frontend Authorization Code + PKCE flow and is opt-in: with no OIDC_ISSUER/OIDC_CLIENT_ID and no local methods enabled everything no-ops and the service behaves as before (open), so the demo and tests are unaffected.

Index

Constants

View Source
const (
	OrgRoleOwner  = "owner"
	OrgRoleAdmin  = "admin"
	OrgRoleMember = "member"
)

Reserved org roles. Owner and admin gate the self-service org endpoints (member management, invites); stores accept any validOrgRole token, so apps may define additional roles and gate on them with RequireOrgHTTP.

Variables

View Source
var (
	ErrNoUser       = errors.New("authx: no such user")
	ErrNoCredential = errors.New("authx: no credential")
	ErrTokenInvalid = errors.New("authx: token invalid or expired")
	// ErrEmailConflict is returned by a reference Authorizer's identity upsert when a login's
	// email already belongs to a different, unproven (unverified, non-bootstrap) account row.
	// Refusing rather than merging is what prevents account takeover; surface it as a 409.
	ErrEmailConflict = errors.New("authx: an account already exists for this email")
)

Sentinels returned by a CredentialStore so the auth package can branch without knowing the storage layer (GORM lives in the tenant package).

View Source
var (
	// ErrNoOrg is returned by OrgStore lookups when an organization doesn't exist.
	ErrNoOrg = errors.New("authx: no such organization")
	// ErrNotOrgMember is returned by OrgRole when the user has no membership in the org.
	ErrNotOrgMember = errors.New("authx: not a member of this organization")
)

Sentinels returned by an OrgStore.

View Source
var ErrAccessDenied = errors.New("access denied")

ErrAccessDenied refuses a login from an Authorizer.

View Source
var ErrNoGroup = errors.New("authx: no such group")

ErrNoGroup is returned by DirectoryStore lookups when a group doesn't exist.

View Source
var ErrOrgOwnerDeprovision = errors.New("authx: cannot deprovision an organization owner via SCIM — manage owners in-app")

ErrOrgOwnerDeprovision is returned by an org-scoped directory when a customer IdP tries to deprovision an org OWNER via SCIM. Exported so the SCIM server can render it as a 403 policy refusal instead of a 500 (a 5xx an IdP would retry forever) — owners are managed in-app.

Functions

func GroupsFromRequest

func GroupsFromRequest(r *http.Request) []string

GroupsFromRequest returns the groups of the session attached by GateHTTP.

func KeyHasScope

func KeyHasScope(info *APIKeyInfo, scope string) bool

KeyHasScope reports whether an API key may use a given scope. Least privilege: an EMPTY Scopes list grants NOTHING. "*" grants everything; otherwise the scope must be listed explicitly.

func NewHIBPBreachChecker added in v0.5.0

func NewHIBPBreachChecker() *hibpChecker

NewHIBPBreachChecker returns a fail-open HIBP checker (a HIBP outage degrades to the embedded list rather than blocking signups). It sends the Add-Padding header to blunt response-size analysis and times out quickly so a slow upstream can't stall the signup/reset path.

func SessionFromRequest

func SessionFromRequest(r *http.Request) (sub, email string, ok bool)

SessionFromRequest returns the authenticated subject + email from a request gated by GateHTTP.

func TrustedProxies

func TrustedProxies() []string

TrustedProxies returns the proxy IPs/CIDRs to trust when extracting the client IP from X-Forwarded-For, read from TRUSTED_PROXIES (comma-separated) or a private-range default that matches the usual reverse-proxy-on-a-private-network deployment. Without it XFF is honored from any peer, letting a client spoof it to defeat per-IP rate limits.

Types

type APIKeyInfo

type APIKeyInfo struct {
	ID         string     `json:"id"` // opaque; see AuthUser.ID
	Name       string     `json:"name"`
	Prefix     string     `json:"prefix"` // leading chars of the key, for identification in lists
	Groups     []string   `json:"groups"`
	Scopes     []string   `json:"scopes,omitempty"` // deny-by-default; e.g. ["scim"], ["admin"], ["*"]
	ExpiresAt  *time.Time `json:"expiresAt,omitempty"`
	CreatedAt  time.Time  `json:"createdAt"`
	LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
	// OrgID binds the key to one organization ("" = a global key, the default). An org-bound key
	// is refused by every GLOBAL surface — adminGuard, ValidateAPIKeyScope, group merging — and
	// only satisfies ValidateOrgAPIKeyScope for its own org (e.g. that org's SCIM mount).
	OrgID string `json:"orgId,omitempty"`
}

APIKeyInfo is the non-secret metadata of an API key. The raw key is shown ONCE at creation; only its sha256 is stored. Calls made with the key act with the key's Groups for access control, and are limited to the key's Scopes (e.g. "admin", "scim"). Scopes are DENY-BY-DEFAULT: an empty list grants nothing; grant ["*"] for an unrestricted ("root") key.

type AuthUser

type AuthUser struct {
	ID            string
	Sub           string
	Email         string
	Name          string
	EmailVerified bool
	Disabled      bool
	// Ban is a time-boxed login block with a reason, distinct from Disabled (an operator off-switch).
	// Banned + zero BannedUntil = permanent; Banned + a future BannedUntil = until that instant.
	Banned      bool
	BannedUntil time.Time
	BanReason   string
	// Timestamps for SCIM meta.created / meta.lastModified. Best-effort — a store that doesn't
	// track them leaves them zero and SCIM omits the field.
	CreatedAt time.Time
	UpdatedAt time.Time
}

AuthUser is the auth package's storage-agnostic view of a control-plane user.

ID is an OPAQUE string: the auth package never parses it or does arithmetic on it, it only passes it back to the store. A store keyed by uuid/ULID/KSUID returns those verbatim; the reference stores keep numeric PKs and convert to/from decimal strings at their boundary (no DB migration). Empty ("") is the reserved "no user" sentinel — a store must never mint it as a real ID.

type Authenticator

type Authenticator struct {
	// contains filtered or unexported fields
}

Authenticator holds the OIDC client wiring. When auth is disabled it is a valid no-op value (Enabled() == false) so callers never special-case nil.

func New

func New(ctx context.Context, cfg Config) (*Authenticator, error)

New builds the Authenticator. If auth is not configured it returns a disabled (no-op) instance. If it IS configured it performs OIDC discovery against the issuer and fails on error — we'd rather refuse to boot than run unprotected when auth was intended (fail closed).

func (*Authenticator) AccountChangeEmailRequest added in v0.5.0

func (a *Authenticator) AccountChangeEmailRequest(c *reqCtx)

AccountChangeEmailRequest (POST /auth/api/account/email) starts a verified email change after a fresh step-up: it emails a confirmation link to the NEW address and never writes the new email until that link is redeemed (preserving the verified-email invariant), and notifies the OLD address.

func (*Authenticator) AccountDelete added in v0.5.0

func (a *Authenticator) AccountDelete(c *reqCtx)

AccountDelete (DELETE /auth/api/account) hard-deletes the signed-in user's account after a fresh step-up. Irreversible: it cascades credentials/passkeys/tokens/OAuth/2FA, anonymizes the audit trail, revokes all sessions, and clears the caller's cookies. Data your app keyed on the user's Sub is your responsibility to remove — that boundary ends at this library's tables.

func (*Authenticator) AccountInfo

func (a *Authenticator) AccountInfo(c *reqCtx)

AccountInfo (GET /auth/api/account) returns the signed-in user's identity + sign-in methods.

func (*Authenticator) AccountSetPassword

func (a *Authenticator) AccountSetPassword(c *reqCtx)

AccountSetPassword (POST /auth/api/account/password) sets or changes the signed-in user's password. If one already exists, the current password is required.

func (*Authenticator) AuthConfig

func (a *Authenticator) AuthConfig(c *reqCtx)

AuthConfig (GET /auth/config) is public and tells the SPA which sign-in methods are available, so the login UI renders only what's wired this phase.

func (*Authenticator) CSRFHTTP

func (a *Authenticator) CSRFHTTP(next http.Handler) http.Handler

CSRFHTTP is net/http middleware that enforces the double-submit CSRF token on state-changing /api and /auth requests, skipping the unauthenticated entry points and social callbacks.

func (*Authenticator) Callback

func (a *Authenticator) Callback(c *reqCtx)

Callback completes the flow: validate state, exchange the code, verify the ID token, enforce group access, then set the session cookie.

func (*Authenticator) Close added in v0.4.0

func (a *Authenticator) Close() error

Close stops the background rate-limiter GC goroutine started for the built-in in-memory limiters. Optional: call it when discarding an Authenticator in a long-lived process (e.g. tests spinning up many instances) to avoid leaking the ticker goroutine. Safe to call once; a no-op if no built-in limiters were installed.

func (*Authenticator) DirectoryEnabled

func (a *Authenticator) DirectoryEnabled() bool

DirectoryEnabled reports whether the directory features are wired.

func (*Authenticator) EmailChangeConfirm added in v0.5.0

func (a *Authenticator) EmailChangeConfirm(c *reqCtx)

EmailChangeConfirm (GET /auth/email/change?token=) redeems the new-address confirmation token and rebinds the email. Redeeming proves control of the new address, so it lands verified.

func (*Authenticator) EmailLogin

func (a *Authenticator) EmailLogin(c *reqCtx)

EmailLogin (GET /auth/email/login?token=) redeems a magic-login token and signs the user in. Redeeming the emailed link proves email control, so it also confirms the address.

func (*Authenticator) EmailOTPSend added in v0.5.0

func (a *Authenticator) EmailOTPSend(c *reqCtx)

EmailOTPSend (POST /auth/email-otp/send) emails a 6-digit sign-in code. Always 200 (anti-enumeration); the lookup + send happen off the request path so timing doesn't reveal whether the account exists.

func (*Authenticator) EmailOTPVerify added in v0.5.0

func (a *Authenticator) EmailOTPVerify(c *reqCtx)

EmailOTPVerify (POST /auth/email-otp/verify) checks a code and, on success, signs the user in through the shared completeLogin funnel. The store enforces the per-code attempt cap; we add the per-IP guard.

func (*Authenticator) EmailRequest

func (a *Authenticator) EmailRequest(c *reqCtx)

EmailRequest (POST /auth/email/request) emails a one-time sign-in (magic) link. Always 200 (anti-enumeration).

func (*Authenticator) EmailVerify

func (a *Authenticator) EmailVerify(c *reqCtx)

EmailVerify (GET /auth/email/verify?token=) redeems a verify-email token, marks the email verified, and signs the user in.

func (*Authenticator) Enabled

func (a *Authenticator) Enabled() bool

Enabled reports whether auth is enforced.

func (*Authenticator) GateHTTP

func (a *Authenticator) GateHTTP(next http.Handler) http.Handler

GateHTTP is net/http middleware that enforces a valid session: public paths pass; missing/invalid sessions get a 401 JSON for /api and /ws, else a redirect to the branded landing. A valid API-key Bearer is also admitted (for RequireGroupsHTTP downstream). On success it stashes the session in the request context (see SessionFromRequest) and ensures a CSRF cookie. No-op when auth isn't enforced.

func (*Authenticator) Handler

func (a *Authenticator) Handler() http.Handler

Handler returns the /auth/* endpoints as a standard http.Handler (a net/http ServeMux wrapped in CSRF enforcement). Mount it in any router — no framework dependency:

mux.Handle("/auth/", authn.Handler())   // net/http, chi, echo, ...

func (*Authenticator) LocalEnabled

func (a *Authenticator) LocalEnabled() bool

LocalEnabled reports whether in-app auth methods are active (flag on + a credential store).

func (*Authenticator) Login

func (a *Authenticator) Login(c *reqCtx)

Login starts the Authorization Code + PKCE flow: stash state/nonce/verifier/next in a short-lived signed cookie and redirect to the IdP.

func (*Authenticator) Logout

func (a *Authenticator) Logout(c *reqCtx)

Logout clears the session and, if the IdP advertises one, redirects to its RP-initiated logout endpoint.

func (*Authenticator) Me

func (a *Authenticator) Me(c *reqCtx)

Me reports auth status + the current identity for the SPA. Always 200. authEnabled follows enforcing() — true when ANY auth method gates requests (OIDC OR in-app local auth) — so a password/passkey-only deployment still reports signed-in state.

func (a *Authenticator) OAuthUnlink(c *reqCtx)

OAuthUnlink (DELETE /auth/api/identities/{provider}) removes a linked social/OIDC identity, refusing to strip the user's last sign-in method. Not step-up gated (parity with PasskeyRemove, and so an OAuth-only user with two providers can still unlink one).

func (*Authenticator) OrgInviteAccept added in v0.6.0

func (a *Authenticator) OrgInviteAccept(c *reqCtx)

OrgInviteAccept (GET /auth/org/invite/accept?token=) redeems an invite from the emailed link. The org and role come from the stored RECORD — nothing in the URL can be tampered with. The signed-in user's email must MATCH the invited address — the token proves control of that mailbox, so acceptance also marks the email verified (same rule as the magic link). Unauthenticated clicks bounce to the login page with the accept URL as next, so the invitee can sign in or register first. These are browser link-clicks, so failures redirect with a message rather than returning JSON (mirrors redeemAndLogin).

func (*Authenticator) OrgInviteCreate added in v0.6.0

func (a *Authenticator) OrgInviteCreate(c *reqCtx)

OrgInviteCreate (POST /auth/org/invites, body {"email","role"}) emails a single-use invite to join the ACTIVE org, stored as a first-class record (list with GET, revoke with DELETE /{id}); re-inviting an address replaces its pending invite. Owner/admin only; only an owner may invite an owner. The link is ONLY ever emailed — possession of the token is the invitee's proof of mailbox control, so handing it to the inviter would let them mint memberships for addresses they don't own.

func (*Authenticator) OrgInviteList added in v0.7.0

func (a *Authenticator) OrgInviteList(c *reqCtx)

OrgInviteList (GET /auth/org/invites) lists the ACTIVE org's PENDING invitations (owner/admin) — the management surface a bare token could never offer.

func (*Authenticator) OrgInviteRevoke added in v0.7.0

func (a *Authenticator) OrgInviteRevoke(c *reqCtx)

OrgInviteRevoke (DELETE /auth/org/invites/{id}) cancels a pending invitation before it is redeemed (owner/admin). Idempotent — revoking an already-gone invite is not an error.

func (*Authenticator) OrgList added in v0.6.0

func (a *Authenticator) OrgList(c *reqCtx)

OrgList (GET /auth/orgs) lists the signed-in user's org memberships plus the active org, for the app's org picker.

func (*Authenticator) OrgMemberList added in v0.6.0

func (a *Authenticator) OrgMemberList(c *reqCtx)

OrgMemberList (GET /auth/org/members) lists the ACTIVE org's members to any live member.

func (*Authenticator) OrgMemberRemove added in v0.6.0

func (a *Authenticator) OrgMemberRemove(c *reqCtx)

OrgMemberRemove (DELETE /auth/org/members/{userId}) removes a member from the ACTIVE org. Owner/admin only; removing an owner requires an owner; the last owner can't be removed. A member may remove THEMSELF (leave) under the same guards — their cookie is re-minted with no active org.

func (*Authenticator) OrgMemberSetRole added in v0.6.0

func (a *Authenticator) OrgMemberSetRole(c *reqCtx)

OrgMemberSetRole (POST /auth/org/members/{userId}, body {"role": "..."}) changes a member's role in the ACTIVE org. Owner/admin only; anything touching the owner role (granting it, or changing an existing owner's role) requires an owner, and the last owner can't be demoted.

func (*Authenticator) OrgSwitch added in v0.6.0

func (a *Authenticator) OrgSwitch(c *reqCtx)

OrgSwitch (POST /auth/org/switch) changes the session's ACTIVE org after verifying LIVE membership, re-minting the cookie with its remaining lifetime. Body: {"slug": "<slug>"} resolves by slug ONLY; {"org": "<id or slug>"} resolves as an ID first, slug fallback — pass the explicit slug field when a slug could collide with another org's opaque ID (e.g. an all-digit slug against the reference stores' decimal IDs). An empty body clears the active org. Membership and existence failures are the same 403, so the endpoint is not an org-ID existence oracle.

func (*Authenticator) OrgsEnabled added in v0.6.0

func (a *Authenticator) OrgsEnabled() bool

OrgsEnabled reports whether the org layer is active: an OrgStore AND a CredentialStore are wired (memberships reference credential-store user IDs, so orgs cannot operate without one).

func (*Authenticator) PasskeyRemove

func (a *Authenticator) PasskeyRemove(c *reqCtx)

PasskeyRemove (DELETE /auth/api/passkeys/:id) deletes one of the user's passkeys, refusing to remove their last remaining sign-in method.

func (*Authenticator) PasskeyRename added in v0.5.0

func (a *Authenticator) PasskeyRename(c *reqCtx)

PasskeyRename (POST /auth/api/passkeys/{id}) relabels one of the user's passkeys.

func (*Authenticator) PasswordLogin

func (a *Authenticator) PasswordLogin(c *reqCtx)

PasswordLogin (POST /auth/password/login) verifies email+password and starts a session.

func (*Authenticator) PasswordResetConfirm

func (a *Authenticator) PasswordResetConfirm(c *reqCtx)

PasswordResetConfirm (POST /auth/password/reset/confirm) redeems a reset token and sets a new password. The token proves email control, so we also mark the email verified.

func (*Authenticator) PasswordResetRequest

func (a *Authenticator) PasswordResetRequest(c *reqCtx)

PasswordResetRequest (POST /auth/password/reset/request) emails a reset link. Always 200.

func (*Authenticator) PasswordSignup

func (a *Authenticator) PasswordSignup(c *reqCtx)

PasswordSignup (POST /auth/password/signup) creates a local, unverified account and emails a verification link. Always responds 200 with a generic message (anti-enumeration); it never overwrites the password of an existing VERIFIED account.

func (*Authenticator) ReAuth added in v0.2.0

func (a *Authenticator) ReAuth(c *reqCtx)

ReAuth (POST /auth/reauth, session-gated) re-verifies the signed-in user with a password OR a 2FA code, and on success sets the step-up cookie.

func (*Authenticator) RequireGroupsHTTP

func (a *Authenticator) RequireGroupsHTTP(groups ...string) func(http.Handler) http.Handler

RequireGroupsHTTP is net/http middleware permitting only principals (a session user gated by GateHTTP, OR a valid API key) in at least one of the named groups. Empty names = any AUTHENTICATED principal (a session or a valid key) — NOT anonymous access; an unauthenticated request is refused.

func (*Authenticator) RequireOrgGroupsHTTP added in v0.7.0

func (a *Authenticator) RequireOrgGroupsHTTP(groups ...string) func(http.Handler) http.Handler

RequireOrgGroupsHTTP is net/http middleware permitting only a session user (gated by GateHTTP) who is a LIVE member of their ACTIVE org and belongs to at least one of the named ORG-SCOPED groups there. Like RequireOrgHTTP, everything is re-verified against the stores per request — org groups never ride in the cookie. Empty names = any live org member. API-key principals are refused (org keys authorize surfaces, not group gates).

func (*Authenticator) RequireOrgHTTP added in v0.6.0

func (a *Authenticator) RequireOrgHTTP(roles ...string) func(http.Handler) http.Handler

RequireOrgHTTP is net/http middleware permitting only a session user (gated by GateHTTP) whose LIVE role in their ACTIVE org is one of the named roles. Empty roles = any current member. Membership is re-verified against the OrgStore per request (one indexed lookup) — see liveOrgRole. API-key principals carry no org, so they are refused; org-scoped keys are a later phase. NOTE: this gates "who is acting in which org" — row-level isolation of the app's own data remains the app's responsibility.

func (*Authenticator) RequireStepUpHTTP added in v0.2.0

func (a *Authenticator) RequireStepUpHTTP(maxAge time.Duration) func(http.Handler) http.Handler

RequireStepUpHTTP wraps a handler so it requires a re-authentication within maxAge. On a stale/absent step-up it responds 403 {"error":"reauth_required"} — the client prompts for POST /auth/reauth, then retries.

func (*Authenticator) SessionList added in v0.5.0

func (a *Authenticator) SessionList(c *reqCtx)

SessionList (GET /auth/api/sessions) lists the signed-in user's active sessions, flagging the one making this request. Requires a session store.

func (*Authenticator) SessionRevoke added in v0.5.0

func (a *Authenticator) SessionRevoke(c *reqCtx)

SessionRevoke (DELETE /auth/api/sessions/{sid}) revokes one of the user's own sessions. It verifies the SID belongs to the caller before revoking, so a user can't revoke another account's session.

func (*Authenticator) SessionRevokeOthers added in v0.5.0

func (a *Authenticator) SessionRevokeOthers(c *reqCtx)

SessionRevokeOthers (POST /auth/api/sessions/revoke-others) revokes every session except the current one — "sign out my other devices".

func (*Authenticator) SessionsEnabled added in v0.5.0

func (a *Authenticator) SessionsEnabled() bool

SessionsEnabled reports whether server-side session tracking/revocation is wired.

func (*Authenticator) SetAuthorizer

func (a *Authenticator) SetAuthorizer(z Authorizer)

SetAuthorizer installs an optional post-login authorization hook.

func (*Authenticator) SetBreachChecker added in v0.5.0

func (a *Authenticator) SetBreachChecker(b BreachChecker)

SetBreachChecker installs an optional password-breach checker (e.g. the HIBP k-anonymity checker). Pass nil to disable. Also enabled at boot via HIBP_BREACH_CHECK=true (see NewHIBPBreachChecker).

func (*Authenticator) SetCredentialStore

func (a *Authenticator) SetCredentialStore(cs CredentialStore)

SetCredentialStore installs the in-app credential persistence (enables local methods).

func (*Authenticator) SetDirectoryStore

func (a *Authenticator) SetDirectoryStore(d DirectoryStore)

SetDirectoryStore enables groups, API keys, per-group access control, and the admin REST API.

func (*Authenticator) SetLocalEnabled

func (a *Authenticator) SetLocalEnabled(on bool)

SetLocalEnabled toggles whether the in-app auth methods (password / passkey / email magic-link + OTP) are wired (mirrors the AUTH_LOCAL env flag). OIDC and social login are independent of this flag — each social provider activates on its own credentials, and OIDC on its issuer.

func (*Authenticator) SetMailer

func (a *Authenticator) SetMailer(m Mailer)

SetMailer installs the email sender for magic-link/verify/reset mails.

func (*Authenticator) SetOrgStore added in v0.6.0

func (a *Authenticator) SetOrgStore(o OrgStore)

SetOrgStore installs the optional organization persistence, enabling the org layer (active-org session claim, /auth/org/* + /auth/orgs endpoints, invites, RequireOrgHTTP, admin org verbs). Org features also need a CredentialStore — memberships are keyed by its user IDs.

func (*Authenticator) SetRateLimiters

func (a *Authenticator) SetRateLimiters(perIP, perAccount RateLimiter)

SetRateLimiters overrides the per-IP and per-account limiters with custom (e.g. Redis-backed) backends. Call it during setup, BEFORE auth is enabled — once set, the built-in in-memory defaults (and their GC ticker) are not installed. Both arguments are required.

func (*Authenticator) SetSessionStore added in v0.5.0

func (a *Authenticator) SetSessionStore(s SessionStore)

SetSessionStore enables optional server-side session revocation + device listing.

func (*Authenticator) SetTwoFactorStore added in v0.2.0

func (a *Authenticator) SetTwoFactorStore(s TwoFactorStore)

SetTwoFactorStore enables optional TOTP two-factor auth + recovery codes. It also installs the default rate limiters if none exist yet: the 2FA verify / reauth endpoints throttle a brute-forceable 6-digit code space, and those guards would silently no-op in an OIDC/social-only deployment that never called SetLocalEnabled. No-op if the consumer already supplied limiters via SetRateLimiters.

func (*Authenticator) SocialCallback

func (a *Authenticator) SocialCallback(c *reqCtx)

SocialCallback (GET /auth/social/:provider/callback) verifies state, exchanges the code, reads the provider-verified identity, links/creates the local user, and signs them in.

func (*Authenticator) SocialLogin

func (a *Authenticator) SocialLogin(c *reqCtx)

SocialLogin (GET /auth/social/:provider/login) starts the provider's Authorization Code + PKCE flow, stashing state/nonce/verifier/next in the signed flow cookie (same machinery as OIDC).

func (*Authenticator) StepUpFresh added in v0.2.0

func (a *Authenticator) StepUpFresh(r *http.Request, maxAge time.Duration) bool

StepUpFresh reports whether the request carries a valid step-up proof for the CURRENT session user, issued within maxAge. (Bound to the session subject so a step-up from one account can't elevate another.)

func (*Authenticator) StopImpersonating added in v0.5.0

func (a *Authenticator) StopImpersonating(c *reqCtx)

StopImpersonating (POST /auth/api/stop-impersonating) ends an impersonation session. It revokes the impersonation session and clears the cookies; the admin then signs back in with their own account (clean, and it avoids resurrecting a dropped OIDC id_token from the original admin session).

func (*Authenticator) TOTPBegin added in v0.2.0

func (a *Authenticator) TOTPBegin(c *reqCtx)

TOTPBegin (POST /auth/2fa/totp/begin, session-gated) starts enrollment: generate a fresh secret (pending, not yet enabled) and return the otpauth:// URI + base32 secret. The consumer renders the QR; enrollment completes at /confirm with a valid code.

func (*Authenticator) TOTPConfirm added in v0.2.0

func (a *Authenticator) TOTPConfirm(c *reqCtx)

TOTPConfirm (POST /auth/2fa/totp/confirm, session-gated) finishes enrollment: validate a code against the pending secret, enable TOTP, and return single-use recovery codes ONCE.

func (*Authenticator) TOTPDisable added in v0.2.0

func (a *Authenticator) TOTPDisable(c *reqCtx)

TOTPDisable (POST /auth/2fa/disable, session-gated) turns 2FA off, requiring a current TOTP or recovery code so a walked-up session can't silently disable it.

func (*Authenticator) TwoFactorEnabled added in v0.2.0

func (a *Authenticator) TwoFactorEnabled() bool

TwoFactorEnabled reports whether 2FA is wired at all (a store is set).

func (*Authenticator) TwoFactorPending added in v0.2.0

func (a *Authenticator) TwoFactorPending(c *reqCtx)

TwoFactorPending (GET /auth/2fa/pending) reports whether a login is awaiting a second factor — the SPA uses it after a social/OIDC redirect to decide whether to show the 2FA prompt.

func (*Authenticator) TwoFactorVerify added in v0.2.0

func (a *Authenticator) TwoFactorVerify(c *reqCtx)

TwoFactorVerify (POST /auth/2fa/verify) completes a login halted for a second factor: read the short-lived 2fa-pending cookie, validate a TOTP code OR consume a recovery code, then mint the real session.

func (*Authenticator) TwoFactorWebauthnBegin added in v0.2.0

func (a *Authenticator) TwoFactorWebauthnBegin(c *reqCtx)

TwoFactorWebauthnBegin (POST /auth/2fa/webauthn/begin) starts a passkey assertion for the pending user's registered credentials.

func (*Authenticator) TwoFactorWebauthnFinish added in v0.2.0

func (a *Authenticator) TwoFactorWebauthnFinish(c *reqCtx)

TwoFactorWebauthnFinish (POST /auth/2fa/webauthn/finish) verifies the passkey assertion and, on success, mints the real session — completing the login halted for a second factor.

func (*Authenticator) ValidateAPIKey

func (a *Authenticator) ValidateAPIKey(raw string) (*APIKeyInfo, bool)

ValidateAPIKey resolves a raw API key to its info if it exists and is not expired (and stamps last-used). Useful for wiring API-key auth into other surfaces (e.g. the SCIM server).

func (*Authenticator) ValidateAPIKeyScope

func (a *Authenticator) ValidateAPIKeyScope(raw, scope string) bool

ValidateAPIKeyScope is ValidateAPIKey plus a scope check — convenient for gating a GLOBAL surface (e.g. the global SCIM server) to keys that carry a specific scope:

scim.NewServer(store, func(t string) bool { return authn.ValidateAPIKeyScope(t, "scim") })

Org-bound keys (OrgID != "") are REFUSED here — a key bound to one org grants nothing outside it; gate per-org surfaces with ValidateOrgAPIKeyScope instead.

func (*Authenticator) ValidateOrgAPIKeyScope added in v0.7.0

func (a *Authenticator) ValidateOrgAPIKeyScope(raw, scope, orgID string) bool

ValidateOrgAPIKeyScope is ValidateAPIKey plus a scope check that honors org binding: a GLOBAL key with the scope passes everywhere; an ORG-BOUND key passes only for its own org. This is the gate to hand an org's SCIM mount:

scim.NewServer(view, func(t string) bool { return authn.ValidateOrgAPIKeyScope(t, "scim", org.ID) })

func (*Authenticator) WebauthnLoginBegin

func (a *Authenticator) WebauthnLoginBegin(c *reqCtx)

WebauthnLoginBegin (POST /auth/webauthn/login/begin) starts a discoverable passkey login. Discoverable (usernameless) login sends an EMPTY allowCredentials list, which is exactly what makes the browser offer "use a phone or tablet" — the FIDO2 hybrid transport that shows a QR code so the user can sign in with a passkey on another device. No server-side QR handling is needed; the platform negotiates it as long as we stay discoverable and the rpID is fixed.

func (*Authenticator) WebauthnLoginFinish

func (a *Authenticator) WebauthnLoginFinish(c *reqCtx)

WebauthnLoginFinish (POST /auth/webauthn/login/finish?next=) verifies the assertion and signs the user in. The assertion is the body; the redirect target comes via ?next=.

func (*Authenticator) WebauthnRegisterBegin

func (a *Authenticator) WebauthnRegisterBegin(c *reqCtx)

WebauthnRegisterBegin (POST /auth/webauthn/register/begin) starts passkey enrolment for the signed-in user.

func (*Authenticator) WebauthnRegisterFinish

func (a *Authenticator) WebauthnRegisterFinish(c *reqCtx)

WebauthnRegisterFinish (POST /auth/webauthn/register/finish?name=) stores the new passkey. The WebAuthn attestation is the request body; the optional label comes via ?name=.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, id Identity) (role string, err error)
}

Authorizer is an optional post-login hook (e.g. multi-tenant provisioning). It may run side effects (create the user's account, accept invites) and returns the role to embed in the session, or ErrAccessDenied to refuse the login.

type BreachChecker added in v0.5.0

type BreachChecker interface {
	Pwned(ctx context.Context, password string) (bool, error)
}

BreachChecker reports whether a candidate password appears in a known-breach corpus. It is consulted on signup, password reset, and password change, in ADDITION to the embedded common-password list.

A non-nil error means the check could not be COMPLETED (e.g. the upstream service was unreachable). The checker itself decides fail-open vs fail-closed: a fail-open checker swallows transport errors and returns (false, nil); a fail-closed checker returns the error, and the engine then refuses the password rather than let a possibly-breached one through.

type Config

type Config struct {
	Issuer        string   // OIDC_ISSUER, e.g. https://id.example.com
	ClientID      string   // OIDC_CLIENT_ID
	ClientSecret  string   // OIDC_CLIENT_SECRET (confidential client)
	RedirectURL   string   // OIDC_REDIRECT_URL, e.g. https://app.example.com/auth/callback
	AllowedGroups []string // OIDC_ALLOWED_GROUPS (comma-sep); empty = any authenticated user
	SessionSecret []byte   // SESSION_SECRET — HMAC key for the session/flow cookies
	// PreviousSessionSecrets are retired signing keys still accepted for VERIFICATION (never signing),
	// so SESSION_SECRET can be rotated without logging every user out at once. Populate from
	// SESSION_SECRET_PREVIOUS (comma-separated) or programmatically; keep an old key only for one
	// session TTL after rotation, then drop it (each entry widens the trusted-signer set). Each must
	// also be at least 32 bytes.
	PreviousSessionSecrets [][]byte
	CookieSecure           bool // COOKIE_SECURE — force the Secure flag (also auto-on under TLS)
	// TrustedOrigins is an optional allow-list of exact origins (scheme://host[:port]) accepted on
	// state-changing requests as defense-in-depth ON TOP OF the double-submit CSRF token. AppURL's
	// origin is always allowed. Empty (and an absent Origin/Referer) fails open — the token remains
	// the primary check — so this never rejects a legitimate same-origin request.
	TrustedOrigins []string
	// BreachCheckHIBP (HIBP_BREACH_CHECK=true) wires the default Have I Been Pwned k-anonymity password
	// checker at boot. It can also be set programmatically via SetBreachChecker with a custom checker.
	BreachCheckHIBP bool
	// AppURL (APP_URL) is the public origin of the app, used to build email links and
	// post-login redirects (e.g. https://trader.savin.nyc). Falls back to RedirectURL's origin.
	AppURL string
	// OwnerEmail (OWNER_EMAIL) is exempt from hard per-account lockout so the operator can't
	// be locked out mid-migration (recovery via email link stays open regardless).
	OwnerEmail string
	// BrandName (BRAND_NAME) labels the auth emails and is the default WebAuthn relying-party
	// display name. Falls back to "go-auth-x" when unset.
	BrandName string
	// OIDCAssumeVerified (OIDC_ASSUME_VERIFIED) trusts the issuer's email as verified even when the
	// id_token omits an email_verified claim. Leave false (default) to require the claim — the
	// correct posture for multi-IdP setups; set true only for a single, fully-trusted issuer that
	// doesn't emit the claim.
	OIDCAssumeVerified bool
	// PublicPath, if set, is consulted (in addition to the always-public /auth, /_next, health
	// routes) to decide which paths the gate lets through unauthenticated — e.g. your SPA's login
	// pages. nil = the built-in reference defaults (/login, /register, /welcome, …).
	PublicPath func(path string) bool
	// Social login (OAuth). Each provider turns on only when its client id + secret are set.
	// Callback URLs are AppURL + /auth/social/<provider>/callback — one per enabled provider
	// (google, github, facebook, apple, microsoft, discord, and any SocialOIDC slug) — register
	// each upstream.
	GoogleClientID       string // GOOGLE_CLIENT_ID
	GoogleClientSecret   string // GOOGLE_CLIENT_SECRET
	GitHubClientID       string // GITHUB_CLIENT_ID
	GitHubClientSecret   string // GITHUB_CLIENT_SECRET
	FacebookClientID     string // FACEBOOK_CLIENT_ID
	FacebookClientSecret string // FACEBOOK_CLIENT_SECRET
	// Sign in with Apple. The "client secret" is an ES256 JWT the library signs from your private
	// key, so Apple needs four values: the Services ID (client id), your Team ID, the Key ID, and the
	// .p8 EC private key (PEM; literal or with \n-escaped newlines). Apple posts its callback
	// (form_post), so its flow cookie is set SameSite=None — HTTPS is required.
	AppleClientID   string // APPLE_CLIENT_ID (Services ID)
	AppleTeamID     string // APPLE_TEAM_ID
	AppleKeyID      string // APPLE_KEY_ID
	ApplePrivateKey string // APPLE_PRIVATE_KEY (.p8 PEM)

	// Microsoft / Entra ID (OIDC). Tenant defaults to "common" (any work/school or personal Microsoft
	// account) — a MULTI-TENANT endpoint whose token email is NOT trustworthy: any Entra tenant can mint
	// a token asserting any address (nOAuth). Set MICROSOFT_TENANT to a single tenant GUID / verified
	// custom domain to restrict sign-in to that organization, which is the ONLY configuration in which the
	// token email is trusted. With a multi-tenant endpoint the email is treated as unverified, so Entra's
	// (which omits email_verified) sign-ins are refused until a tenant is pinned. MicrosoftStrictEmailVerified
	// additionally requires an explicit email_verified even for a pinned single tenant.
	MicrosoftClientID            string // MICROSOFT_CLIENT_ID
	MicrosoftClientSecret        string // MICROSOFT_CLIENT_SECRET
	MicrosoftTenant              string // MICROSOFT_TENANT (default "common" — pin a single tenant to enable email sign-in)
	MicrosoftStrictEmailVerified bool   // MICROSOFT_STRICT_EMAIL_VERIFIED

	// Discord (OAuth2 + REST; requires a verified email).
	DiscordClientID     string // DISCORD_CLIENT_ID
	DiscordClientSecret string // DISCORD_CLIENT_SECRET

	// SocialOIDC registers arbitrary standards-compliant OIDC identity providers as social logins
	// (GitLab, Okta, Auth0, Keycloak, …), each mounted at /auth/social/<Name>/{login,callback}.
	// Programmatic only (set on the Config) — there is no env form.
	SocialOIDC []SocialOIDCProvider
}

Config is read from the environment at boot. Auth turns on only when an issuer and client id are present.

func ConfigFromEnv

func ConfigFromEnv() Config

ConfigFromEnv loads the OIDC configuration from the environment.

func (Config) Enabled

func (c Config) Enabled() bool

Enabled reports whether auth should be enforced. The session secret is required too — without it the signed cookies would be unsafe.

type CredentialStore

type CredentialStore interface {
	// Identity
	UserByEmail(email string) (*AuthUser, error)
	UserBySub(sub string) (*AuthUser, error)
	CreateLocalUser(email, name string) (*AuthUser, error) // Sub="local:<uuid>", EmailVerified=false
	SetEmailVerified(userID string, verified bool) error
	// SetEmail changes a user's email (used by the verified change-email flow AFTER the new address is
	// proven). It must uphold the one-user-per-email invariant, returning ErrEmailConflict if another
	// user already owns newEmail.
	SetEmail(userID, newEmail string) error
	// DeleteUser hard-deletes a user and everything keyed to it (credentials, passkeys, tokens, OAuth
	// links, 2FA, group memberships). Audit rows are retained but any PII (email/IP) is anonymized, so
	// the forensic count survives an erasure. Idempotent: deleting an absent user is not an error.
	DeleteUser(userID string) error

	// Passkeys (WebAuthn)
	EnsureWebauthnHandle(userID string) ([]byte, error)    // get-or-create the stable user handle
	UserByWebauthnHandle(handle []byte) (*AuthUser, error) // resolve a discoverable login
	Passkeys(userID string) ([]Passkey, error)
	AddPasskey(userID string, p Passkey) error
	TouchPasskey(credentialID []byte, signCount uint32) error // update sign count + last-used
	RemovePasskey(userID, id string) error
	RenamePasskey(userID, id, name string) error // relabel a passkey; no-op if not the user's

	// Password
	PasswordHash(userID string) (hash, algo string, err error) // ErrNoCredential if unset
	SetPasswordHash(userID, hash, algo string) error

	// Social (OAuth) identities — natural key (provider, subject), NOT email, so an upstream
	// email change doesn't fork the account.
	UserByOAuth(provider, subject string) (*AuthUser, error) // ErrNoUser if unlinked
	LinkOAuth(userID, provider, subject, email string) error // idempotent per (provider,subject)
	UnlinkOAuth(userID, provider string) error               // remove the user's link to a provider
	OAuthIdentities(userID string) ([]string, error)         // provider slugs the user has linked

	// Single-use, hashed-at-rest email tokens (magic-link, verify-email, password-reset, invite)
	CreateToken(purpose, userID, email string, tokenHash []byte, expiresAt time.Time) error
	ConsumeToken(purpose string, tokenHash []byte) (*TokenClaim, error) // marks consumed atomically; ErrTokenInvalid
	// PeekToken validates a token (exists, unconsumed, unexpired) and returns its claim WITHOUT consuming
	// it, so a caller can validate downstream input (e.g. the new password) before burning a single-use
	// link. ErrTokenInvalid on a miss/expiry.
	PeekToken(purpose string, tokenHash []byte) (*TokenClaim, error)

	// Email OTP (short numeric codes). Unlike the 256-bit tokens above these are brute-forceable, so
	// they are scoped by (purpose,email) — NOT matched globally by hash — and carry an attempt counter.
	// CreateEmailOTP replaces any existing OTP for (purpose,email). VerifyEmailOTP atomically compares
	// (constant-time), increments the attempt count, and invalidates the code on success OR once
	// maxAttempts is exceeded; it returns ErrTokenInvalid on any miss/expiry/exhaustion.
	CreateEmailOTP(purpose, email string, codeHash []byte, expiresAt time.Time, maxAttempts int) error
	VerifyEmailOTP(purpose, email string, codeHash []byte) (*TokenClaim, error)

	// Audit + lockout. A userID of "" means "no user" (e.g. a failed login before resolution) —
	// the store records the row without keying it to an account.
	RecordAudit(userID, email, ip, method, event string, success bool, detail string)
	RecentFailures(email string, since time.Time) (int, error)
}

CredentialStore is the persistence the in-app auth methods need. It is implemented by the control plane (tenant package), so the auth package stays storage-agnostic — the same boundary the Authorizer hook uses. Lookups return ErrNoUser / ErrNoCredential when absent. The passkey and social-identity methods are added in later phases.

type DirectoryStore

type DirectoryStore interface {
	// Groups
	CreateGroup(name, description string) (*Group, error)
	Groups() ([]Group, error)
	GroupByName(name string) (*Group, error) // ErrNoGroup if absent
	DeleteGroup(id string) error
	AddUserToGroup(userID, groupID string) error
	RemoveUserFromGroup(userID, groupID string) error
	UserGroups(userID string) ([]Group, error)
	GroupMembers(groupID string) ([]AuthUser, error)

	// Admin user management
	ListUsers() ([]AuthUser, error)
	UserByID(id string) (*AuthUser, error)       // ErrNoUser if absent
	UserByEmail(email string) (*AuthUser, error) // ErrNoUser if absent — used by SCIM to enforce create-uniqueness
	SetUserDisabled(userID string, disabled bool) error
	// SetUserBan sets or clears a time-boxed ban with a reason. banned=false clears it; a nil `until`
	// with banned=true is a permanent ban, else the ban lifts at *until.
	SetUserBan(userID string, banned bool, until *time.Time, reason string) error

	// UpsertExternalUser provisions/updates a user from an external directory (LDAP/SCIM) keyed by
	// an external Sub (e.g. "ldap:<uid>" / "scim:<id>"). Applies the same safe email-linking rule.
	UpsertExternalUser(sub, email, name string, emailVerified bool) (*AuthUser, error)

	// API keys (sha256-at-rest). APIKeyByHash returns ErrNoCredential if absent and does NOT
	// itself check expiry (the caller does, so an expired key can still be listed/revoked).
	CreateAPIKey(name string, groups, scopes []string, prefix string, hash []byte, expiresAt *time.Time) (*APIKeyInfo, error)
	APIKeyByHash(hash []byte) (*APIKeyInfo, error)
	ListAPIKeys() ([]APIKeyInfo, error)
	RevokeAPIKey(id string) error
	TouchAPIKey(id string) error // best-effort last-used stamp
}

DirectoryStore is the OPTIONAL persistence for groups, group membership, API keys, and admin user management. Wire it with SetDirectoryStore to enable groups, per-group access control (RequireGroups), API-key auth, and the admin REST API. It is intentionally separate from CredentialStore, so consumers that only need authentication don't have to implement it.

func NewOrgScopedDirectory added in v0.7.0

func NewOrgScopedDirectory(dir DirectoryStore, orgs OrgStore, orgID string) (DirectoryStore, error)

NewOrgScopedDirectory wraps a DirectoryStore in a view confined to orgID, satisfying scim.NewServer so each customer's IdP provisions ONLY its own organization:

view, _ := authx.NewOrgScopedDirectory(store, store, org.ID)
scim.NewServer(view, func(t string) bool { return authn.ValidateOrgAPIKeyScope(t, "scim", org.ID) })

Org-scoped semantics differ from the global SCIM mount in three deliberate ways:

  • Provisioned subjects are NAMESPACED per org ("scim:org<id>:<userName>"), so the same userName pushed by two customers yields two accounts, not one shared identity.
  • An existing account whose email a customer's IdP asserts is NEVER adopted or rebound (ErrEmailConflict → SCIM 409): users stay global, and bringing an existing account into an org requires the consent-based invite flow. Only accounts this org's IdP provisioned (or that already belong to the org) can be updated through the view.
  • Deprovisioning (SCIM active=false / DELETE) removes the user FROM THE ORG (membership + its org groups via the RemoveOrgMember contract) — never the global account, which may belong to other orgs. Org owners cannot be deprovisioned via SCIM (manage owners in-app).

type Group

type Group struct {
	ID          string    `json:"id"`
	Name        string    `json:"name"`
	Description string    `json:"description"`
	CreatedAt   time.Time `json:"createdAt"`
	UpdatedAt   time.Time `json:"updatedAt,omitempty"` // for SCIM meta.lastModified; zero if the store doesn't track it
	// OrgID binds an org-scoped group to its organization ("" = a global group, the default).
	// Names are unique per (org, name). Org-scoped groups are invisible to the global verbs
	// (Groups / GroupByName) and never ride in the session cookie — they are managed through
	// OrgDirectoryStore and checked live by RequireOrgGroupsHTTP.
	OrgID string `json:"orgId,omitempty"`
}

Group is a named collection of users used for access control. ID is opaque (see AuthUser.ID).

type H

type H = map[string]any

H is a generic JSON object for handler responses (mirrors gin.H, minus the framework).

type Identity

type Identity struct {
	Subject string
	Email   string
	Name    string
	Groups  []string
	// EmailVerified reports whether THIS login proved the email — the OIDC email_verified claim,
	// a provider-verified social email, or a redeemed in-app token. The reference Authorizer feeds
	// it to the safe account-linking rule (only a proven email may reclaim/adopt another row).
	EmailVerified bool
}

Identity is the authenticated identity handed to an Authorizer after login.

type Mailer

type Mailer interface {
	Send(to, subject, htmlBody, textBody string) error
	Configured() bool
}

Mailer sends the in-app auth emails. Implemented by internal/email; the auth package owns the message templates and calls Send. nil/!Configured() disables email methods.

type Org added in v0.6.0

type Org struct {
	ID        string    `json:"id"`
	Slug      string    `json:"slug"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt,omitempty"` // zero if the store doesn't track it
}

Org is a tenant/organization: a named collection of users with per-org roles, layered ABOVE authentication. Users stay global (one account, many orgs) — an org never owns a user, so the one-user-per-email invariant and the safe account-linking rule are unaffected.

ID is an opaque string (uuid/ULID-friendly, see roadmap #2); the reference stores use their numeric PKs and convert at the boundary. Slug is the unique, URL-safe handle.

type OrgDirectoryStore added in v0.7.0

type OrgDirectoryStore interface {
	// Org-scoped groups. CreateOrgGroup enforces (org, name) uniqueness; OrgGroupByName returns
	// ErrNoGroup when absent. OrgOfGroup resolves which org owns a group ID ("" = global,
	// ErrNoGroup if the group doesn't exist) — the check every by-ID group operation gates on.
	CreateOrgGroup(orgID, name, description string) (*Group, error)
	OrgGroups(orgID string) ([]Group, error)
	OrgGroupByName(orgID, name string) (*Group, error)
	OrgOfGroup(groupID string) (string, error)
	UserOrgGroups(orgID, userID string) ([]Group, error)

	// Org-bound API keys. An org-bound key (OrgID != "") is refused by every GLOBAL surface
	// (adminGuard, ValidateAPIKeyScope, group merging) and only satisfies ValidateOrgAPIKeyScope
	// for ITS org. ListAPIKeys (global) still lists all keys — the OrgID field says which are bound.
	CreateOrgAPIKey(orgID, name string, groups, scopes []string, prefix string, hash []byte, expiresAt *time.Time) (*APIKeyInfo, error)
	ListOrgAPIKeys(orgID string) ([]APIKeyInfo, error)
}

OrgDirectoryStore is an OPTIONAL upgrade interface a DirectoryStore may implement to support org-scoped resources: groups and API keys bound to one organization (the v0.7 phase). It is detected via type assertion — a DirectoryStore that doesn't implement it simply has the org-scoped features off (the handlers 501), so existing custom stores keep compiling unchanged.

Org-scoped groups live in the same namespace as global groups but are invisible to the global verbs: Groups()/GroupByName() return ONLY global groups, and group names are unique per (org, name) — two orgs may both have "engineering". Org-scoped groups never ride in the session cookie (see RequireOrgGroupsHTTP, which checks them live).

type OrgInvite added in v0.7.0

type OrgInvite struct {
	ID        string    `json:"id"` // opaque; see AuthUser.ID
	OrgID     string    `json:"orgId"`
	Email     string    `json:"email"`
	Role      string    `json:"role"`
	InvitedBy string    `json:"invitedBy,omitempty"` // inviter's email, for the pending-invites UI
	ExpiresAt time.Time `json:"expiresAt"`
	CreatedAt time.Time `json:"createdAt"`
}

OrgInvite is a pending, first-class invitation record: listable and revocable, unlike a bare token. The raw invite token is emailed once and only its hash is stored; the org + role bind to the RECORD, so nothing security-relevant rides in the accept URL.

type OrgMember added in v0.6.0

type OrgMember struct {
	User AuthUser `json:"user"`
	Role string   `json:"role"`
}

OrgMember is one of an org's members (user-joined, for member lists).

type OrgStore added in v0.6.0

type OrgStore interface {
	// Orgs. CreateOrg must enforce slug uniqueness (the handlers pre-check and 409, the store's
	// constraint is the fail-closed backstop under races). DeleteOrg cascades memberships and is
	// idempotent. Lookups return ErrNoOrg when absent.
	CreateOrg(slug, name string) (*Org, error)
	OrgByID(id string) (*Org, error)
	OrgBySlug(slug string) (*Org, error)
	Orgs() ([]Org, error)
	RenameOrg(id, name string) error
	DeleteOrg(id string) error

	// Membership. SetOrgMember UPSERTS (adds the user or updates their role) — role-change policy
	// (who may grant what, the last-owner guard) is enforced by the callers, not the store. It
	// returns ErrNoOrg when the org is absent and ErrNoUser when the user doesn't exist (a
	// dangling row would be invisible in OrgMembers yet inherited by a future user assigned that
	// ID). RemoveOrgMember is idempotent and ALSO removes the user from the org's org-scoped
	// groups when the store supports them — org-group membership must not outlive org membership.
	// OrgRole returns ErrNotOrgMember when there is no membership (ErrNoOrg when the org itself
	// is absent).
	SetOrgMember(orgID, userID, role string) error
	RemoveOrgMember(orgID, userID string) error
	OrgRole(orgID, userID string) (string, error)
	UserOrgs(userID string) ([]UserOrg, error)
	OrgMembers(orgID string) ([]OrgMember, error)

	// Invitations — first-class records (listable + revocable), hashed-at-rest like every other
	// token. CreateOrgInvite REPLACES any pending invite for (org, email), so re-inviting updates
	// the role/expiry instead of stacking rows. OrgInvites returns the PENDING set (unconsumed,
	// unexpired, unrevoked). RevokeOrgInvite is idempotent. PeekOrgInvite validates a token
	// WITHOUT consuming it (so the wrong account can't burn a valid invite); ConsumeOrgInvite
	// atomically marks it used — both return ErrTokenInvalid on any miss/expiry/revocation.
	CreateOrgInvite(orgID, email, role, invitedBy string, tokenHash []byte, expiresAt time.Time) (*OrgInvite, error)
	OrgInvites(orgID string) ([]OrgInvite, error)
	RevokeOrgInvite(orgID, id string) error
	PeekOrgInvite(tokenHash []byte) (*OrgInvite, error)
	ConsumeOrgInvite(tokenHash []byte) (*OrgInvite, error)
}

OrgStore is the OPTIONAL persistence for organizations and their memberships. Wire it with SetOrgStore to enable the org layer: an active-org session claim, org switching, member management, email invites, RequireOrgHTTP, and the admin org verbs. Like the other capability stores, nil = the feature is off with zero behavior change.

Memberships are keyed by the CredentialStore's user ID, so org features also require a CredentialStore (see OrgsEnabled).

type Passkey

type Passkey struct {
	ID              string // opaque; see AuthUser.ID
	CredentialID    []byte
	PublicKey       []byte
	AttestationType string
	AAGUID          []byte
	SignCount       uint32
	Transports      string // CSV of authenticator transports
	BackupEligible  bool
	BackupState     bool
	Name            string
	CreatedAt       time.Time
	LastUsedAt      time.Time
}

Passkey is the auth package's storage-agnostic view of a registered WebAuthn credential.

type RateLimiter

type RateLimiter interface {
	Allow(key string) bool
}

RateLimiter throttles auth attempts keyed by an arbitrary string (a client IP or an account email). Allow records ONE attempt for key and reports whether it remains within budget (false = throttle). Implementations MUST be safe for concurrent use. The built-in default is in-memory and per-process; for a multi-replica deployment supply a shared-store (e.g. Redis) implementation via SetRateLimiters so the budget is coherent across replicas.

type SessionClaims

type SessionClaims struct {
	Email   string   `json:"email,omitempty"`
	Name    string   `json:"name,omitempty"`
	Groups  []string `json:"groups,omitempty"`
	Role    string   `json:"role,omitempty"` // optional role from an Authorizer
	IDToken string   `json:"idt,omitempty"`  // raw OIDC id_token — used as id_token_hint at RP-initiated logout
	// SID is a random per-session id, present only when an optional SessionStore is wired. It lets the
	// gate consult server-side revocation (sign-out-everywhere, list/revoke devices). Empty = the
	// default stateless behavior (no server-side session record).
	SID string `json:"sid,omitempty"`
	// ImpersonatedBy is the admin subject when this session was minted by admin impersonation. Surfaced
	// at /auth/me so the app can show a banner, and refused by adminGuard so an impersonated session
	// can't perform further admin actions.
	ImpersonatedBy string `json:"imp,omitempty"`
	// Org is the ACTIVE organization's ID when an OrgStore is wired — the sole membership at login,
	// or whatever POST /auth/org/switch re-minted. Only the active org rides in the cookie (the full
	// membership list is served by /auth/me); empty = no active org.
	Org string `json:"org,omitempty"`
	// OrgRole is the user's role in Org AT MINT TIME — a display hint. Enforcement (RequireOrgHTTP,
	// the org endpoints) re-reads the live role from the store, so a demotion doesn't ride out the TTL.
	OrgRole string `json:"orgRole,omitempty"`
	jwt.RegisteredClaims
}

SessionClaims is the signed session-cookie payload — the authenticated identity.

type SessionRecord added in v0.5.0

type SessionRecord struct {
	SID       string    `json:"id"`
	Subject   string    `json:"-"`
	UserID    string    `json:"-"` // opaque; see AuthUser.ID ("" when the session isn't tied to a stored user)
	UserAgent string    `json:"userAgent,omitempty"`
	IP        string    `json:"ip,omitempty"`
	CreatedAt time.Time `json:"createdAt"`
	ExpiresAt time.Time `json:"expiresAt"`
	Current   bool      `json:"current"` // set by the engine when listing; not persisted
}

SessionRecord is a server-side record of an issued session, used for revocation and device listing. It is written at login and read back for the "your sessions" list; only non-secret metadata.

type SessionStore added in v0.5.0

type SessionStore interface {
	// RecordSession persists a freshly minted session (best-effort; a failure must not block login).
	RecordSession(rec SessionRecord) error
	// IsRevoked reports whether the session with this SID must be rejected. It returns true for an
	// explicitly-revoked session AND for an UNKNOWN sid: because RecordSession runs at mint and rows are
	// only ever tombstoned (never deleted), a missing record means a lost write, and honoring it would
	// leave an un-revocable session. Return (false, err) on a genuine store error so the caller can fail
	// open (a transient outage must not mass-logout). Only sessions with a non-empty SID are checked.
	IsRevoked(sid string) (bool, error)
	RevokeSession(sid string) error
	RevokeAllForUser(subject string) error
	ListSessionsForUser(subject string) ([]SessionRecord, error)
}

SessionStore is OPTIONAL persistence that layers server-side revocation over the stateless signed-cookie default. Wire it with SetSessionStore to enable sign-out-everywhere, per-device list/revoke, and ban-revokes-all. Sessions minted before a store is configured carry no SID and are simply not tracked (they remain valid until they expire) — enable the store from the start for full coverage. It mirrors the pluggable RateLimiter seam: nil = today's pure-stateless behavior.

type SocialOIDCProvider added in v0.3.0

type SocialOIDCProvider struct {
	Name         string   // URL slug (lowercased), e.g. "gitlab", "okta", "auth0"
	Issuer       string   // OIDC issuer URL
	ClientID     string   // OAuth client id
	ClientSecret string   // OAuth client secret (confidential client)
	Scopes       []string // optional; defaults to openid, email, profile
	// AssumeVerified trusts the token's email when the provider omits email_verified (e.g. a
	// tenant that owns its addresses). Off by default — an absent/false email_verified is refused.
	AssumeVerified bool
}

SocialOIDCProvider registers any OIDC-compliant identity provider as a social login. Discovery uses <Issuer>/.well-known/openid-configuration; sign-in follows the same Authorization Code + PKCE + nonce + azp + email_verified path as Google.

type TOTPInfo added in v0.2.0

type TOTPInfo struct {
	Secret   string
	Enabled  bool
	LastStep uint64
}

TOTPInfo is a user's stored TOTP state. Secret is the base32 shared secret; Enabled is false while enrollment is pending (secret set but not yet confirmed with a valid code); LastStep is the most recently consumed time-step, used to reject a replayed code.

type TokenClaim

type TokenClaim struct {
	UserID string // opaque; see AuthUser.ID
	Email  string
}

TokenClaim is what a redeemed single-use token resolves to.

type TwoFactorStore added in v0.2.0

type TwoFactorStore interface {
	TOTP(userID string) (*TOTPInfo, error)            // ErrNoCredential if the user has no TOTP
	SetTOTPSecret(userID, secret string) error        // (re)enroll: store secret, Enabled=false, LastStep=0
	EnableTOTP(userID string) error                   // confirm enrollment
	DisableTOTP(userID string) error                  // remove TOTP + all recovery codes
	SetTOTPLastStep(userID string, step uint64) error // set the enrollment step (no concurrency concern)
	// ClaimTOTPStep atomically records a just-verified time-step ONLY if it advances past LastStep,
	// returning true iff it did. This is the replay guard for login verification: the compare and the
	// write must be one atomic step so two concurrent requests can't both accept the same code.
	ClaimTOTPStep(userID string, step uint64) (bool, error)

	ReplaceRecoveryCodes(userID string, hashes [][]byte) error    // set at enrollment (replaces any existing)
	ConsumeRecoveryCode(userID string, hash []byte) (bool, error) // true if it existed and was removed
	RecoveryCodesRemaining(userID string) (int, error)
}

TwoFactorStore is OPTIONAL persistence for TOTP + recovery codes. Wire it with SetTwoFactorStore to enable two-factor auth. It's separate from CredentialStore so consumers that don't want 2FA are unaffected. User ids match CredentialStore. Recovery codes are stored as sha256 hashes; consuming one removes it. Secrets should be encrypted at rest by the implementation.

type UserOrg added in v0.6.0

type UserOrg struct {
	Org  Org    `json:"org"`
	Role string `json:"role"`
}

UserOrg is one of a user's org memberships (org-joined, for the org picker).

Directories

Path Synopsis
Package ldapsync syncs users and groups from an LDAP/Active Directory server into an authx.DirectoryStore (and the user records behind it).
Package ldapsync syncs users and groups from an LDAP/Active Directory server into an authx.DirectoryStore (and the user records behind it).
Package email is a minimal SMTP sender for the in-app auth flows (magic-link, email verification, password reset, invites).
Package email is a minimal SMTP sender for the in-app auth flows (magic-link, email verification, password reset, invites).
Package scim is a SCIM 2.0 provisioning server (Users + Groups) over an authx.DirectoryStore, so an upstream IdP (Okta, Entra/Azure AD, JumpCloud) can push and deprovision users and groups.
Package scim is a SCIM 2.0 provisioning server (Users + Groups) over an authx.DirectoryStore, so an upstream IdP (Okta, Entra/Azure AD, JumpCloud) can push and deprovision users and groups.
store
gormstore
Package gormstore is a reference GORM-backed implementation of authx.CredentialStore plus a safe reference Authorizer (identity upsert with verified-email account-linking).
Package gormstore is a reference GORM-backed implementation of authx.CredentialStore plus a safe reference Authorizer (identity upsert with verified-email account-linking).
memory
Package memory is a zero-dependency, in-memory reference implementation of authx.CredentialStore (+ the safe verified-email linking rule).
Package memory is a zero-dependency, in-memory reference implementation of authx.CredentialStore (+ the safe verified-email linking rule).

Jump to

Keyboard shortcuts

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