From 391a4fc6050df335b555a8b7d5b6f2cd77840faf Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 13 Oct 2026 11:19:43 -0500
Subject: [PATCH 1/5] fix: park third-party OAuth2 JWTs until a scoped route
admits them
On an app configured for JWT access tokens, a token the OAuth2 provider
issues to a client is now held back by the auth middleware until a route
tagged with RequireScope admits it, the same as an opaque token. Before
this, that token authenticated as the full user on every route, whatever
scopes the client was granted. Userinfo also returned every claim, since
it saw no session and took the caller for a first-party login.
Parking keys off the session on the request context. The opaque path loads
the session row and finds its ClientID there, but the JWT path loads no
row, so a client's JWT never looked like a client's token. JWTs now carry
the RFC 9068 client_id claim, and tryJWTAuth rebuilds a session from the
claims when it's present, which parkOAuthSession then parks.
Both places that mint a JWT set the claim: issueTokens in the plugin, and
the engine's rotateSession, which the OAuth2 refresh grant goes through.
Without the second one a token would escape again on its first refresh.
Tokens minted before this change carry no client_id and keep full access
until they expire. If you can't wait out one access token lifetime, revoke
the outstanding OAuth2 grants when you deploy.
Covered by TestOAuth2IssuedJWTIsParkedUntilAScopedRouteAdmitsIt, which
issues a token through the real authorize and token endpoints, refreshes
it, and presents both tokens to the engine's middleware.
---
middleware/auth.go | 21 ++++
oauth2_jwt_scope_e2e_test.go | 167 +++++++++++++++++++++++++++++++
plugins/oauth2provider/plugin.go | 1 +
service.go | 10 +-
tokenformat/format.go | 8 ++
tokenformat/jwt.go | 4 +
6 files changed, 208 insertions(+), 3 deletions(-)
create mode 100644 oauth2_jwt_scope_e2e_test.go
diff --git a/middleware/auth.go b/middleware/auth.go
index c0ab2c3f..bc84321c 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -692,6 +692,27 @@ func tryJWTAuth(
}
goCtx = WithUser(goCtx, u)
+ // A token issued to an OAuth2 client must be parked like its opaque
+ // counterpart, and parking keys off the session on the context. The
+ // opaque path gets that session from its row; this path loads no row, so
+ // rebuild the part parking and RequireScope read from the claims. Without
+ // it a third-party token authenticated as the user on every route.
+ if claims.ClientID != "" {
+ sess := &session.Session{
+ AppID: appID,
+ UserID: subjectID,
+ ClientID: claims.ClientID,
+ Scopes: claims.Scopes,
+ Audience: claims.Audience,
+ DPoPJKT: claims.DPoPJKT,
+ ExpiresAt: claims.ExpiresAt,
+ }
+ if sid, sidOK := SessionIDFrom(goCtx); sidOK {
+ sess.ID = sid
+ }
+ goCtx = WithSession(goCtx, sess)
+ }
+
goCtx = parkOAuthSession(ctx.Context(), goCtx)
ctx.WithContext(goCtx)
return jwtAuthAuthenticated, nil
diff --git a/oauth2_jwt_scope_e2e_test.go b/oauth2_jwt_scope_e2e_test.go
new file mode 100644
index 00000000..796f555d
--- /dev/null
+++ b/oauth2_jwt_scope_e2e_test.go
@@ -0,0 +1,167 @@
+package authsome_test
+
+// An OAuth2 access token is issued to a third party for a set of scopes, so
+// the auth middleware parks it until a RequireScope route admits it. In
+// opaque mode the middleware finds the session row, sees its ClientID and
+// parks it. In JWT mode there is no row lookup, so the token itself has to
+// say which client it belongs to. Before it did, a JWT minted by the
+// /v1/oauth/token endpoint authenticated as the full user on every route.
+//
+// This test issues the token through the real grant and presents it to the
+// engine's real middleware, because the bug lived at exactly that seam: each
+// half had its own passing tests.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/xraph/forge"
+ "golang.org/x/crypto/bcrypt"
+
+ authsome "github.com/xraph/authsome"
+ "github.com/xraph/authsome/account"
+ "github.com/xraph/authsome/id"
+ "github.com/xraph/authsome/middleware"
+ "github.com/xraph/authsome/plugins/oauth2provider"
+ "github.com/xraph/authsome/tokenformat"
+)
+
+// issueOAuth2JWT runs an authorization-code flow for scope "profile" against
+// a plugin initialised from eng, so the token endpoint mints with the app's
+// JWT format. It returns the token response and a function that redeems a
+// refresh token at the same endpoint.
+func issueOAuth2JWT(t *testing.T, eng *authsome.Engine, appID id.AppID) (*oauth2provider.TokenResponse, func(string) *oauth2provider.TokenResponse) {
+ t.Helper()
+ ctx := context.Background()
+
+ _, sess, err := eng.SignUp(ctx, &account.SignUpRequest{
+ AppID: appID,
+ Email: "oauth2-jwt@example.com",
+ Password: "SecureP@ss123",
+ FirstName: "JWT Scope",
+ })
+ require.NoError(t, err)
+
+ p := oauth2provider.New(oauth2provider.Config{Issuer: "https://auth.example.com"})
+ oauthStore := oauth2provider.NewMemoryStore()
+ p.SetOAuth2Store(oauthStore)
+ require.NoError(t, p.OnInit(ctx, eng))
+
+ hashed, err := bcrypt.GenerateFromPassword([]byte(e2eClientSecret), bcrypt.MinCost)
+ require.NoError(t, err)
+ require.NoError(t, oauthStore.CreateClient(ctx, &oauth2provider.OAuth2Client{
+ ID: id.NewOAuth2ClientID(),
+ AppID: appID,
+ ClientID: e2eClientID,
+ FirstParty: true,
+ ClientSecret: string(hashed),
+ Name: "Third Party",
+ RedirectURIs: []string{e2eRedirectURI},
+ Scopes: []string{"openid", "profile"},
+ GrantTypes: []string{"authorization_code"},
+ }))
+
+ mux := forge.NewRouter()
+ require.NoError(t, p.RegisterRoutes(mux))
+
+ q := url.Values{
+ "response_type": {"code"},
+ "client_id": {e2eClientID},
+ "redirect_uri": {e2eRedirectURI},
+ "scope": {"profile"},
+ }
+ authRec := httptest.NewRecorder()
+ mux.ServeHTTP(authRec, httptest.NewRequestWithContext(
+ middleware.WithUserID(ctx, sess.UserID), http.MethodGet,
+ "/v1/oauth/authorize?"+q.Encode(), nil))
+ require.Equal(t, http.StatusFound, authRec.Code, "body: %s", authRec.Body.String())
+ loc, err := url.Parse(authRec.Header().Get("Location"))
+ require.NoError(t, err)
+ code := loc.Query().Get("code")
+ require.NotEmpty(t, code)
+
+ redeem := func(form map[string]string) *oauth2provider.TokenResponse {
+ form["client_id"] = e2eClientID
+ form["client_secret"] = e2eClientSecret
+ body, err := json.Marshal(form)
+ require.NoError(t, err)
+ tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/oauth/token", bytes.NewReader(body))
+ tokReq.Header.Set("Content-Type", "application/json")
+ tokRec := httptest.NewRecorder()
+ mux.ServeHTTP(tokRec, tokReq)
+ require.Equal(t, http.StatusOK, tokRec.Code, "body: %s", tokRec.Body.String())
+
+ var resp oauth2provider.TokenResponse
+ require.NoError(t, json.Unmarshal(tokRec.Body.Bytes(), &resp))
+ // Guard the premise: an opaque token would be parked through its
+ // session row and this test would pass without exercising the JWT
+ // path at all.
+ require.True(t, tokenformat.IsJWT(resp.AccessToken), "the app is JWT-configured, so the grant must mint a JWT")
+ return &resp
+ }
+
+ resp := redeem(map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": e2eRedirectURI,
+ })
+ refresh := func(refreshToken string) *oauth2provider.TokenResponse {
+ return redeem(map[string]string{"grant_type": "refresh_token", "refresh_token": refreshToken})
+ }
+ return resp, refresh
+}
+
+func TestOAuth2IssuedJWTIsParkedUntilAScopedRouteAdmitsIt(t *testing.T) {
+ jwtFmt, err := tokenformat.NewJWT(tokenformat.JWTConfig{
+ SigningMethod: jwt.SigningMethodHS256,
+ SigningKey: []byte("test-signing-key-0123456789abcdef"),
+ })
+ require.NoError(t, err)
+ eng, _ := newTestEngine(t, authsome.WithJWTFormat("aapp_01jf0000000000000000000000", jwtFmt))
+
+ issued, refresh := issueOAuth2JWT(t, eng, testAppID(t))
+ require.NotEmpty(t, issued.RefreshToken)
+
+ whoami := func(ctx forge.Context) error {
+ if _, ok := middleware.UserFrom(ctx.Context()); !ok {
+ return ctx.NoContent(http.StatusUnauthorized)
+ }
+ return ctx.NoContent(http.StatusOK)
+ }
+ router := forge.NewRouter()
+ router.Use(eng.AuthMiddleware())
+ require.NoError(t, router.GET("/untagged", whoami))
+ require.NoError(t, router.GET("/profile", whoami, forge.WithMiddleware(middleware.RequireScope("profile"))))
+ require.NoError(t, router.GET("/admin", whoami, forge.WithMiddleware(middleware.RequireScope("admin"))))
+
+ call := func(token, path string) int {
+ req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec.Code
+ }
+
+ // The refresh grant re-mints through the engine rather than the plugin,
+ // so it is a second place the client binding can fall off the token.
+ tokens := map[string]string{
+ "issued by the code grant": issued.AccessToken,
+ "issued by the refresh grant": refresh(issued.RefreshToken).AccessToken,
+ }
+ for name, token := range tokens {
+ t.Run(name, func(t *testing.T) {
+ assert.Equal(t, http.StatusUnauthorized, call(token, "/untagged"),
+ "a third-party JWT must not authenticate as the user on a route that never declared scopes")
+ assert.Equal(t, http.StatusOK, call(token, "/profile"), "a route asking for a granted scope admits the token")
+ assert.Equal(t, http.StatusForbidden, call(token, "/admin"), "a route asking for an ungranted scope refuses it")
+ })
+ }
+}
diff --git a/plugins/oauth2provider/plugin.go b/plugins/oauth2provider/plugin.go
index 8da9f32f..98c9b304 100644
--- a/plugins/oauth2provider/plugin.go
+++ b/plugins/oauth2provider/plugin.go
@@ -1788,6 +1788,7 @@ func (p *Plugin) issueTokens(ctx forge.Context, client *OAuth2Client, userID id.
Scopes: scopes,
Audience: resources,
DPoPJKT: jkt,
+ ClientID: sess.ClientID,
IssuedAt: sess.CreatedAt,
ExpiresAt: sess.ExpiresAt,
})
diff --git a/service.go b/service.go
index 9100c5ed..669181ff 100644
--- a/service.go
+++ b/service.go
@@ -790,9 +790,13 @@ func (e *Engine) rotateSession(ctx context.Context, sess *session.Session, opts
SessionID: sess.ID.String(),
// Without this the first refresh turns a token bound to one
// resource server into an unrestricted one, silently.
- Audience: sess.Audience,
- Scopes: sess.Scopes,
- DPoPJKT: sess.DPoPJKT,
+ Audience: sess.Audience,
+ Scopes: sess.Scopes,
+ DPoPJKT: sess.DPoPJKT,
+ // A refreshed OAuth2 token is still the client's. Dropping
+ // this would hand the client a token the middleware treats
+ // as the user's own login.
+ ClientID: sess.ClientID,
IssuedAt: sess.UpdatedAt,
ExpiresAt: sess.ExpiresAt,
})
diff --git a/tokenformat/format.go b/tokenformat/format.go
index 86bba74e..6e1b50c5 100644
--- a/tokenformat/format.go
+++ b/tokenformat/format.go
@@ -64,6 +64,14 @@ type TokenClaims struct {
// means a user. That ambiguity is what made the JWT auth path parse an
// empty sub and panic on a machine token.
PrincipalID string `json:"pid,omitempty"`
+
+ // ClientID names the OAuth2 client a token was issued to, and is empty
+ // for a first-party login. It is the RFC 9068 client_id claim. The auth
+ // middleware reads it to park a third-party token until a RequireScope
+ // route admits it; an opaque token gets the same answer from its session
+ // row, but a JWT is validated without loading one, so the token has to
+ // say it.
+ ClientID string `json:"client_id,omitempty"`
}
// Format generates and validates access tokens. Refresh tokens are always
diff --git a/tokenformat/jwt.go b/tokenformat/jwt.go
index f598f853..44f9d1ad 100644
--- a/tokenformat/jwt.go
+++ b/tokenformat/jwt.go
@@ -94,6 +94,8 @@ type customClaims struct {
// Both stay absent on a human token.
PrincipalKind string `json:"pk,omitempty"`
PrincipalID string `json:"pid,omitempty"`
+ // ClientID is the RFC 9068 client_id claim, absent on a first-party token.
+ ClientID string `json:"client_id,omitempty"`
// Confirmation is a pointer with omitempty so an unbound token carries no
// cnf member at all, rather than an empty object. Anything reading these
// tokens treats the presence of cnf as the signal that a proof is required.
@@ -117,6 +119,7 @@ func (j *JWT) GenerateAccessToken(claims TokenClaims) (string, error) {
Act: claims.Act,
PrincipalKind: claims.PrincipalKind,
PrincipalID: claims.PrincipalID,
+ ClientID: claims.ClientID,
}
if claims.DPoPJKT != "" {
@@ -190,6 +193,7 @@ func (j *JWT) ValidateAccessToken(tokenStr string) (*TokenClaims, error) {
Act: claims.Act,
PrincipalKind: claims.PrincipalKind,
PrincipalID: claims.PrincipalID,
+ ClientID: claims.ClientID,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
}, nil
From 2315d749a0207699ece2b6a4d80ecc1a65c085ad Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Wed, 14 Oct 2026 15:27:24 -0500
Subject: [PATCH 2/5] docs: rewrite the README and add a banner
The README now follows the shape of forge's: a banner, a quick start, Forge integration,
configuration, docs links and a FAQ. The quick start was built and run against the local checkout.
The old quick start couldn't run, because the engine needs Warden, Chronicle and a token encryption
key. The routes are listed under /v1, where they really are.
---
README.md | 483 +++++++++++++++++++++------------------
assets/readme-banner.svg | 33 +++
2 files changed, 292 insertions(+), 224 deletions(-)
create mode 100644 assets/readme-banner.svg
diff --git a/README.md b/README.md
index 291f614f..f426a622 100644
--- a/README.md
+++ b/README.md
@@ -1,274 +1,309 @@
-
Authsome
-
A composable, plugin-driven authentication and identity engine for Go.
+# Authsome: authentication and identity for Go
----
+Authsome is an authentication and user management engine for Go. The core gives you users, sessions with refresh tokens, organizations, RBAC, devices, apps and environments; every sign-in method (password, social login, magic links, passkeys and WebAuthn, MFA, SSO, phone, API keys) is a plugin you register, along with risk controls such as GeoIP, impossible travel and VPN detection. It runs inside your Go service against your own database, records every security event to [Chronicle](https://github.com/xraph/chronicle) and checks permissions with [Warden](https://github.com/xraph/warden), and it plugs into [Forge](https://github.com/xraph/forge) as an extension.
-Authsome is a full-featured authentication engine built for the [Forge](https://github.com/xraph/forge) ecosystem. It provides core identity management out of the box — users, sessions, organizations, RBAC, devices, API keys — and ships every authentication method as a swappable plugin so you only pay for what you use.
+[](https://go.dev/dl/) [](https://github.com/xraph/authsome/actions/workflows/ci.yml) [](https://pkg.go.dev/github.com/xraph/authsome) [](https://github.com/xraph/authsome/stargazers)
-
-
-
+[Documentation](https://www.xraph.com/docs/authsome) · [Quick start](#quick-start) · [Features](#why-authsome) · [Plugins](#plugins) · [Forge](#use-it-with-forge) · [Configuration](#configuration) · [Client SDKs](#client-sdks-and-ui) · [FAQ](#faq)
-## Features
-
-### Core Identity
-- **User management** — registration, profiles, email verification, password resets
-- **Session management** — configurable TTLs, refresh token rotation, IP/device binding
-- **Organizations & teams** — multi-tenant with memberships, invitations, and team hierarchy
-- **RBAC** — roles, permissions, and role hierarchies with middleware enforcement
-- **API keys** — scoped keys with expiration and automatic header/bearer detection
-- **Device tracking** — register, trust, and manage user devices
-- **Webhooks** — event-driven integrations for all auth lifecycle events
-- **Security events** — audit trail for every authentication action
-
-### Authentication Methods (Plugins)
-- **Password** — bcrypt/argon2 with configurable policy (length, complexity, history, expiration)
-- **Social OAuth2** — Google, GitHub, and 30+ providers
-- **Magic Links** — passwordless email authentication
-- **Passkeys / WebAuthn** — FIDO2 hardware and platform authenticators
-- **MFA** — TOTP and SMS-based second factors
-- **API Keys** — machine-to-machine authentication
-- **SSO / SAML** — enterprise single sign-on
-
-### Security & Governance
-- Account lockout with configurable thresholds
-- Per-endpoint rate limiting
-- Password history enforcement and expiration
-- Session binding to IP and device fingerprint
-- Anomaly detection, GeoIP, impossible travel, VPN detection, IP reputation (via plugins)
-
-### Admin Dashboard
-A built-in admin dashboard provides a real-time overview of your authentication system — users, sessions, devices, plugins, recent signups, SSO providers, SCIM provisioning, organizations, and more.
-
-### Multi-Database Support
-| Driver | Use Case |
-|------------|----------------|
-| PostgreSQL | Production |
-| SQLite | Development |
-| MongoDB | Alternative |
-| Memory | Testing |
-
-## Quick Start
-
-### Install
+## Quick start
+
+You need [Go 1.26 or later](https://go.dev/dl/).
```bash
-go get github.com/xraph/authsome
+go get github.com/xraph/authsome@latest github.com/xraph/chronicle@latest github.com/xraph/warden@latest
```
-### Standalone Server
+Save this as `main.go`. It runs an auth server with email and password sign-in, using in-memory stores for Authsome, its audit trail and its permissions:
```go
package main
import (
- "context"
- "net/http"
- "os"
- "os/signal"
- "syscall"
-
- "github.com/xraph/forge"
- authsome "github.com/xraph/authsome"
- "github.com/xraph/authsome/api"
- "github.com/xraph/authsome/middleware"
- "github.com/xraph/authsome/plugins/password"
- "github.com/xraph/authsome/store/memory"
+ "context"
+ "log"
+ "net/http"
+
+ "github.com/xraph/chronicle"
+ chstore "github.com/xraph/chronicle/store"
+ chmemory "github.com/xraph/chronicle/store/memory"
+ "github.com/xraph/forge"
+ "github.com/xraph/warden"
+ wmemory "github.com/xraph/warden/store/memory"
+
+ "github.com/xraph/authsome"
+ "github.com/xraph/authsome/api"
+ "github.com/xraph/authsome/bridge/chronicleadapter"
+ "github.com/xraph/authsome/middleware"
+ "github.com/xraph/authsome/plugins/password"
+ "github.com/xraph/authsome/store/memory"
)
func main() {
- store := memory.New()
-
- engine, err := authsome.NewEngine(
- authsome.WithStore(store),
- authsome.WithDisableMigrate(),
-
- // Enable authentication plugins:
- authsome.WithPlugin(password.New()),
- // authsome.WithPlugin(social.New(social.WithGitHub(clientID, secret, callbackURL))),
- // authsome.WithPlugin(magiclink.New()),
- // authsome.WithPlugin(mfa.New()),
- )
- if err != nil {
- panic(err)
- }
-
- if err := engine.Start(context.Background()); err != nil {
- panic(err)
- }
-
- a := api.New(engine)
- router := forge.NewRouter()
- router.Use(middleware.AuthMiddleware(engine.ResolveSessionByToken, engine.ResolveUser, nil))
- a.RegisterRoutes(router)
-
- srv := &http.Server{Addr: ":8080", Handler: router.Handler()}
-
- go func() {
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
- <-sigCh
- engine.Stop(context.Background())
- srv.Close()
- }()
-
- srv.ListenAndServe()
+ ctx := context.Background()
+
+ // Authsome records every security event to Chronicle and checks
+ // permissions with Warden, so both are required.
+ trail, err := chronicle.New(chronicle.WithStore(chstore.NewAdapter(chmemory.New())))
+ if err != nil {
+ log.Fatal(err)
+ }
+ authz, err := warden.NewEngine(warden.WithStore(wmemory.New()))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ engine, err := authsome.NewEngine(
+ authsome.WithStore(memory.New()),
+ authsome.WithChronicle(chronicleadapter.New(trail)),
+ authsome.WithWarden(authz),
+ authsome.WithDisableMigrate(), // the memory store has nothing to migrate
+ authsome.WithPlugin(password.New()),
+ authsome.WithBootstrap(authsome.WithBootstrapAppName("Acme")),
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := engine.Start(ctx); err != nil {
+ log.Fatal(err)
+ }
+ defer engine.Stop(ctx) //nolint:errcheck // example
+
+ router := forge.NewRouter()
+ router.Use(middleware.AuthMiddleware(engine.ResolveSessionByToken, engine.ResolveUser, nil))
+ if err := api.New(engine).RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+ }
+
+ log.Printf("listening on :8080, app_id=%s", engine.PlatformAppID())
+ log.Fatal(http.ListenAndServe(":8080", router.Handler())) //nolint:gosec // example
}
```
+Authsome encrypts the tokens it stores, so give it a key, then run:
+
```bash
-# Sign up
-curl -X POST http://localhost:8080/v1/auth/signup \
- -H 'Content-Type: application/json' \
- -d '{"email":"user@example.com","password":"SecureP@ss1","name":"Alice"}'
+go mod tidy
+export AUTHSOME_TOKEN_ENCRYPTION_KEY=$(openssl rand -hex 32)
+go run .
+# listening on :8080, app_id=aapp_...
+```
-# Sign in
-curl -X POST http://localhost:8080/v1/auth/signin \
- -H 'Content-Type: application/json' \
- -d '{"email":"user@example.com","password":"SecureP@ss1"}'
+Sign a user up, from another terminal. Put the `app_id` from the log line in the body:
-# Get current user
-curl http://localhost:8080/v1/auth/me \
- -H 'Authorization: Bearer '
+```bash
+curl -X POST http://localhost:8080/v1/signup \
+ -H 'Content-Type: application/json' \
+ -d '{"app_id":"aapp_...","email":"alice@example.com","password":"SecureP@ss123"}'
+# {"session_token":"...","refresh_token":"...","expires_at":"...","user":{...}}
-# OpenAPI spec
-curl http://localhost:8080/.well-known/authsome/openapi
+curl http://localhost:8080/v1/me -H 'Authorization: Bearer '
```
-### As a Forge Extension
+`WithBootstrap` created a platform app with its default environments and roles on start, which is why you have an app ID to sign up against. Browser and mobile clients usually send an `X-Publishable-Key` header instead of the ID. One thing that catches people: `/v1/signin` refuses an account whose email isn't verified, because the `auth.require_email_verification` setting is on by default. Verify the address through `/v1/verify-email`, or switch the setting off for local work.
-```go
-app := forge.New(
- forge.WithExtension(authsome.New(
- authsome.WithPlugin(password.New()),
- authsome.WithPlugin(social.New(
- social.WithGoogle(clientID, secret, callbackURL),
- )),
- )),
-)
-```
+The OpenAPI spec for everything you've mounted is at `/.well-known/authsome/openapi`.
-## Plugins
+## Why Authsome?
-Authsome ships with **26 plugins** organized by category:
+Auth in a SaaS product grows sideways. You start with a password form and soon need Google sign-in, organizations with invitations, an admin who can see sessions, and a way to block a login from the wrong country. Authsome keeps those in one engine with one user model, and makes each extra method or control a plugin, so your binary only carries what you register and you add the next one when your customers ask for it.
-| Category | Plugins |
-|----------|---------|
-| **Authentication** | `password`, `social`, `magiclink`, `passkey`, `mfa`, `apikey`, `sso`, `phone` |
-| **Identity** | `organization`, `consent`, `subscription`, `waitlist` |
-| **Communication** | `email`, `notification` |
-| **Risk & Security** | `anomaly`, `geofence`, `geoip`, `impossibletravel`, `ipreputation`, `riskengine`, `sharedsignals`, `vpndetect` |
-| **Provisioning** | `scim`, `deviceverify`, `oauth2provider`, `agentauth` |
+| You need | Authsome gives you |
+| --- | --- |
+| Accounts | Sign-up, sign-in, sign-out, email verification, password reset and change, profile updates and account deletion |
+| Sessions | Session and refresh tokens with configurable lifetimes, refresh rotation, a per-user session cap, session listing and revocation, and JWT or opaque token formats |
+| Sign-in methods | Plugins for password, social login (22 providers including Google, GitHub, Apple and Microsoft), magic links, passkeys, MFA, SSO, phone and API keys |
+| B2B tenancy | Apps and environments in the core, and organizations, members, invitations and teams through the `organization` plugin |
+| Authorization | RBAC backed by [Warden](https://github.com/xraph/warden), with roles seeded from `.warden` files on bootstrap |
+| An audit trail | Every security-relevant action recorded to [Chronicle](https://github.com/xraph/chronicle), with no silent fallback |
+| Abuse controls | Rate limiting, account lockout and breached-password checks, plus GeoIP, geofencing, impossible travel, VPN detection, IP reputation and a risk engine as plugins |
+| To be an identity provider | The `oauth2provider` plugin, and `agentauth` for AI agents acting on a user's behalf |
+| Your database | PostgreSQL, SQLite and MongoDB through [Grove](https://github.com/xraph/grove), plus an in-memory store |
+| Clients | A Go client, a TypeScript client, Dart and Flutter packages, and React, Vue and Next.js UI packages in this repo |
-Every plugin has a README covering what it is for, when not to use it, its config and settings, and its endpoints. Start at [plugins/README.md](plugins/README.md).
+## Plugins
-Enable only the plugins you need:
+Nothing beyond the core is on until you register it. You pick. Pass each plugin to the engine with `authsome.WithPlugin`, or to the Forge extension with `WithPlugins`:
```go
-authsome.WithPlugin(password.New())
-authsome.WithPlugin(social.New(social.WithGitHub(id, secret, cb)))
-authsome.WithPlugin(magiclink.New())
-authsome.WithPlugin(mfa.New())
-authsome.WithPlugin(passkey.New(passkey.WithRPID("example.com")))
+authsome.WithPlugin(password.New()),
+authsome.WithPlugin(mfa.New(mfa.Config{Issuer: "Acme"})),
+authsome.WithPlugin(social.New(social.Config{
+ Providers: []social.Provider{
+ social.NewGitHubProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GITHUB_CLIENT_ID"),
+ ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
+ RedirectURL: "https://app.example.com/authsome/v1/social/github/callback",
+ }),
+ },
+})),
```
-## SDKs
-
-Pre-generated client SDKs are available for multiple languages:
-
-| Language | Package |
-|------------|---------|
-| Go | `github.com/xraph/authsome/sdk/go` |
-| TypeScript | `@xraph/authsome-sdk` |
-| Dart | `authsome_sdk` |
-
-SDKs are auto-generated from the OpenAPI spec at `/.well-known/authsome/openapi`.
-
-## API Overview
-
-### Authentication
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `POST` | `/v1/signup` | Register a new user |
-| `POST` | `/v1/signin` | Authenticate |
-| `POST` | `/v1/signout` | End session |
-| `POST` | `/v1/refresh` | Refresh session token |
-| `POST` | `/v1/forgot-password` | Request password reset |
-| `POST` | `/v1/reset-password` | Complete password reset |
-| `POST` | `/v1/verify-email` | Verify email address |
-
-### User & Session
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/v1/me` | Get current user |
-| `PATCH` | `/v1/me` | Update profile |
-| `GET` | `/v1/sessions` | List active sessions |
-| `DELETE` | `/v1/sessions/:id` | Revoke a session |
-
-### Devices
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/v1/devices` | List devices |
-| `DELETE` | `/v1/devices/:id` | Remove device |
-| `PATCH` | `/v1/devices/:id/trust` | Trust a device |
-
-### Discovery
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/.well-known/authsome/manifest` | Service manifest |
-| `GET` | `/.well-known/authsome/openapi` | OpenAPI 3.x spec |
-
-Full API documentation is available via the auto-generated OpenAPI spec.
+| Category | Plugins |
+| --- | --- |
+| Authentication | `password`, `social`, `magiclink`, `passkey`, `mfa`, `apikey`, `sso`, `phone` |
+| Identity | `organization`, `consent`, `subscription`, `waitlist` |
+| Communication | `email`, `notification` |
+| Risk and security | `anomaly`, `geofence`, `geoip`, `impossibletravel`, `ipreputation`, `riskengine`, `sharedsignals`, `vpndetect` |
+| Provisioning | `scim`, `deviceverify`, `oauth2provider`, `agentauth` |
+| Data | `retention` |
+
+Some depend on others, so watch the order you register them in. `geofence`, `vpndetect` and `impossibletravel` read the location `geoip` resolves, so register `geoip` first, and `sso` and `scim` scope their connections to an organization. [plugins/README.md](plugins/README.md) has a "pick by what you are trying to do" table, the full dependency list and a README per plugin with its config, settings and endpoints.
-## Configuration
+## Storage backends
+
+| Backend | Package | Constructor |
+| --- | --- | --- |
+| In-memory | `store/memory` | `memory.New()` |
+| PostgreSQL | `store/postgres` | `postgres.New(db *grove.DB)` |
+| SQLite | `store/sqlite` | `sqlite.New(db *grove.DB)` |
+| MongoDB | `store/mongo` | `mongo.New(db *grove.DB)` |
+
+All four pass the same store conformance suite in CI, so you can develop on SQLite and deploy on PostgreSQL. Plugins that keep their own tables (social connections, MFA factors, passkeys and others) ship a store per backend too, and the engine runs their migrations alongside its own on start unless you pass `WithDisableMigrate`. MongoDB needs a replica set, because app and organization deletes cascade inside transactions.
+
+## Use it with Forge
+
+The `extension` package registers Authsome as a [Forge extension](https://github.com/xraph/forge#extensions). It reads `extensions.authsome` (or a top-level `authsome` key), builds a store from the container's `*grove.DB`, mounts the API under `/authsome`, runs the bootstrap, and adds the auth middleware to your app.
+
+```bash
+go get github.com/xraph/authsome@latest github.com/xraph/chronicle@latest \
+ github.com/xraph/warden@latest github.com/xraph/grove/extension@latest
+```
```go
-authsome.NewEngine(
- authsome.WithConfig(authsome.Config{
- BasePath: "/auth",
- Session: authsome.SessionConfig{
- TokenTTL: 1 * time.Hour,
- RefreshTokenTTL: 30 * 24 * time.Hour,
- MaxActiveSessions: 5,
- BindToIP: true,
- BindToDevice: true,
- },
- Password: authsome.PasswordConfig{
- MinLength: 10,
- },
- RateLimit: authsome.RateLimitConfig{/* ... */},
- Lockout: authsome.LockoutConfig{/* ... */},
- }),
+import (
+ grove "github.com/xraph/grove/extension"
+
+ authsome "github.com/xraph/authsome/extension"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/password"
+ chronicle "github.com/xraph/chronicle/extension"
+ warden "github.com/xraph/warden/extension"
)
+
+app := forge.NewApp(forge.AppConfig{
+ Name: "acme-api",
+ Extensions: []forge.Extension{
+ grove.New(), // extensions.grove: driver and dsn
+ chronicle.New(), // audit trail, required by authsome
+ warden.New(), // authorization, required by authsome
+ authsome.New(
+ authsome.WithPlugins(
+ password.New(),
+ mfa.New(mfa.Config{Issuer: "Acme"}),
+ ),
+ ),
+ },
+})
```
-## Architecture
+Order matters here, and you'll find out quickly if you get it wrong. Authsome looks for `chronicle.Emitter` and `*warden.Engine` in the container during `Register` and refuses to start without them, so list Chronicle and Warden first. Chronicle in turn wants you to decide who may call its admin API (see its README). Anything else it finds in the container gets wired up as a bridge: [Keysmith](https://github.com/xraph/keysmith) for API keys, [Relay](https://github.com/xraph/relay) for webhooks, [Herald](https://github.com/xraph/herald) for notifications, [Dispatch](https://github.com/xraph/dispatch) for background jobs and [Ledger](https://github.com/xraph/ledger) for billing. Without them it uses no-op bridges.
+
+You still need `AUTHSOME_TOKEN_ENCRYPTION_KEY` (64 hex characters), or `AUTHSOME_TOKEN_ENCRYPTION_KEYS` as `kid:hex` pairs when you rotate, or an encryptor passed with `authsome.WithEngineOption(authsome.WithTokenEncryptor(...))`.
+A service that should trust an Authsome deployment elsewhere can run the extension in client mode with `WithClientMode(portalURL)`, `WithServiceAPIKey` and `WithServiceAppID`.
+
+## Configuration
+
+```yaml
+extensions:
+ authsome:
+ base_path: /authsome
+ grove_database: primary # optional; the default *grove.DB otherwise
+ session:
+ token_ttl: 1h
+ refresh_token_ttl: 720h
+ max_active_sessions: 5 # 0 means unlimited
+ rotate_refresh_token: true
+ password:
+ min_length: 12
+ algorithm: argon2id # bcrypt by default; old hashes are rehashed on sign-in
+ check_breached: true
+ mailer:
+ provider: resend # resend or smtp; nothing is sent otherwise
+ resend:
+ api_key: re_your_resend_key
+ from: auth@example.com
+ bootstrap:
+ app_name: Acme
+ initial_owners: [founder@example.com]
```
-authsome/
- engine.go Core engine — plugin lifecycle, auth strategy
- service.go Business logic — users, sessions, orgs, RBAC
- config.go Configuration types
- api/ REST API handlers and route registration
- middleware/ Auth and RBAC middleware
- plugins/ 26 swappable authentication plugins
- store/ Database backends (pg, sqlite, mongo, memory)
- sdk/ Auto-generated client SDKs (Go, TS, Dart)
- dashboard/ Admin dashboard UI
- ui/ React, Next.js, Vue component libraries
+
+The `Config` struct in [extension/config.go](extension/config.go) also covers `rate_limit`, `lockout`, `retention`, `csrf`, `webhooks`, per-app session settings under `apps`, bootstrap environments and `warden_dir` for your own role files, and the client-mode keys. In code, the extension takes `WithConfig`, `WithPlugin`, `WithPlugins`, `WithEngineOption`, `WithEngineOptions`, `WithBasePath`, `WithDisableRoutes`, `WithDisableMigrate`, `WithGroveDatabase`, `WithRequireConfig` and `WithLogger`.
+
+Outside Forge, pass an `authsome.Config` with `authsome.WithConfig`, or use the individual engine options such as `WithBasePath`, `WithRateLimiter`, `WithLockoutConfig`, `WithDefaultTokenFormat` and `WithJWTFormat`. Settings that admins change at runtime, like email verification, live in the engine's settings manager.
+
+## Client SDKs and UI
+
+| Client | Where |
+| --- | --- |
+| Go | `github.com/xraph/authsome/sdk/go` (package `authclient`, `authclient.NewClient(baseURL)`) |
+| TypeScript | [sdk/](sdk/), package `@authsome/client` |
+| Dart | [sdk/dart](sdk/dart/) |
+| Flutter | [flutter/packages](flutter/packages/): `authsome_core`, `authsome_flutter`, `authsome_flutter_ui` |
+| React, Vue, Next.js | [ui/packages](ui/packages/): `@authsome/ui-core`, `@authsome/ui-components`, `@authsome/ui-react`, `@authsome/ui-vue`, `@authsome/ui-nextjs` |
+
+The clients follow the OpenAPI spec the server serves at `/.well-known/authsome/openapi`, and `/.well-known/authsome/manifest` returns the service version, base path and available endpoints.
+
+## Documentation
+
+| Topic | Where |
+| --- | --- |
+| Getting started | [Getting started](https://www.xraph.com/docs/authsome/getting-started), [architecture](https://www.xraph.com/docs/authsome/architecture), [full example](https://www.xraph.com/docs/authsome/guides/full-example) |
+| Sign-in methods | [Overview](https://www.xraph.com/docs/authsome/authentication/overview), [password](https://www.xraph.com/docs/authsome/authentication/password), [social login](https://www.xraph.com/docs/authsome/authentication/social-login), [passkeys](https://www.xraph.com/docs/authsome/authentication/passkeys), [MFA](https://www.xraph.com/docs/authsome/authentication/mfa), [SSO](https://www.xraph.com/docs/authsome/authentication/sso), [OAuth2 provider](https://www.xraph.com/docs/authsome/authentication/oauth2-provider) |
+| Organizations | [Overview](https://www.xraph.com/docs/authsome/organizations/overview), [members](https://www.xraph.com/docs/authsome/organizations/members), [teams](https://www.xraph.com/docs/authsome/organizations/teams), [environments](https://www.xraph.com/docs/authsome/environments/overview) |
+| Security | [RBAC](https://www.xraph.com/docs/authsome/security/rbac), [rate limiting](https://www.xraph.com/docs/authsome/security/rate-limiting), [lockout](https://www.xraph.com/docs/authsome/security/lockout), [audit trail](https://www.xraph.com/docs/authsome/security/audit-trail), [webhooks](https://www.xraph.com/docs/authsome/security/webhooks) |
+| Integrations | [Forge extension](https://www.xraph.com/docs/authsome/guides/forge-extension), [bridges](https://www.xraph.com/docs/authsome/bridges/overview), [custom plugin](https://www.xraph.com/docs/authsome/guides/custom-plugin), [Next.js app](https://www.xraph.com/docs/authsome/guides/nextjs-app), [UI](https://www.xraph.com/docs/authsome/ui/overview) |
+| Storage and API | [Stores](https://www.xraph.com/docs/authsome/stores/overview), [HTTP API](https://www.xraph.com/docs/authsome/api-reference/http-api) |
+
+The Go API is on [pkg.go.dev](https://pkg.go.dev/github.com/xraph/authsome).
+
+## Examples
+
+[`_examples/standalone`](_examples/standalone/) and [`_examples/forge`](_examples/forge/) show the engine on its own and as a Forge extension. Both are behind the current API: the standalone one doesn't pass a Warden engine, so `NewEngine` refuses it, and the Forge one doesn't register Chronicle or Warden. Follow the quick start and the Forge section above until they're updated.
+
+## FAQ
+
+### How do I add authentication to a Go web app?
+
+Build an engine with a store, Chronicle, Warden and the plugins you want, mount the API on a Forge router, and add `middleware.AuthMiddleware` so handlers can see the current user. Under Forge, the extension does all of that from config.
+
+### Does Authsome support social login and passkeys?
+
+Yes, through the `social` plugin (22 OAuth providers, among them Google, GitHub, Apple, Microsoft and Discord) and the `passkey` plugin for WebAuthn.
+
+### Can Authsome handle multi-tenant B2B apps?
+
+Yes. Apps and environments are part of the core, and the `organization` plugin adds organizations, memberships, invitations and teams, with org-scoped roles through Warden.
+
+### Why does Authsome need Chronicle and Warden?
+
+Every security event has to land in an audit trail, and every permission check needs an authorization engine. If you leave either out, Authsome refuses to start, because an auth server that can't tell you who did what is the one thing your auditor won't accept.
+
+### Can I use Authsome without Forge?
+
+Partly. The engine and stores are plain Go, but the HTTP API registers on a Forge router (`forge.NewRouter()`), which you can serve with the standard `net/http` server as the quick start does.
+
+## Contributing
+
+Issues and focused pull requests are welcome on [GitHub](https://github.com/xraph/authsome/issues).
+
+```bash
+git clone https://github.com/xraph/authsome.git
+cd authsome
+GOWORK=off go test ./... # memory and SQLite
+make check # fmt, vet, golangci-lint and govulncheck
+make test-integration # PostgreSQL and a MongoDB replica set, needs Docker
```
+`make test-integration` runs the same store conformance suite as CI's Postgres and MongoDB job, starting a fresh Mongo replica set each time, because several of those tests leave rows behind that would break a second run against the same database. Commits follow [Conventional Commits](https://www.conventionalcommits.org/). [SECURITY.md](SECURITY.md) explains how to report a vulnerability.
+
## License
-Copyright (c) Xraph. All rights reserved.
+See the [Authsome repository](https://github.com/xraph/authsome) for license terms.
+
+Authsome is part of the [Forge](https://github.com/xraph/forge) forgery stack, built by [Rex Raphael](https://github.com/juicycleff) and [contributors](https://github.com/xraph/authsome/graphs/contributors).
diff --git a/assets/readme-banner.svg b/assets/readme-banner.svg
new file mode 100644
index 00000000..1fd3735f
--- /dev/null
+++ b/assets/readme-banner.svg
@@ -0,0 +1,33 @@
+
From 51f32800ac0b634d95dd516104ee5f354dec9880 Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Wed, 14 Oct 2026 18:20:34 -0500
Subject: [PATCH 3/5] docs: make the docs match the code
Every page under docs/content/docs was checked against the source, and the getting-started,
Forge extension and full-example snippets were built and run, with every documented route
checked against the real route table.
NewEngine needs Warden, Chronicle and a token encryption key, routes live under /v1 and not
/v1/auth, and the organization and environment pages use the real plugin and engine methods,
since auth.Orgs() and auth.Environments() don't exist. The bridge pages use the real mailer, SMS
and Chronicle constructors and say the core sends no email itself. plugins/README.md now counts
27 plugins, retention included.
---
.../docs/api-reference/go-packages.mdx | 36 +-
docs/content/docs/api-reference/http-api.mdx | 148 ++++---
docs/content/docs/architecture.mdx | 125 +++---
.../docs/authentication/magic-links.mdx | 141 +++----
docs/content/docs/authentication/mfa.mdx | 62 ++-
.../docs/authentication/oauth2-provider.mdx | 112 ++----
docs/content/docs/authentication/overview.mdx | 20 +-
docs/content/docs/authentication/passkeys.mdx | 38 +-
docs/content/docs/authentication/password.mdx | 34 +-
.../docs/authentication/phone-auth.mdx | 33 +-
.../docs/authentication/social-login.mdx | 85 ++--
docs/content/docs/authentication/sso.mdx | 220 +++--------
docs/content/docs/bridges/chronicle.mdx | 84 ++--
docs/content/docs/bridges/dispatch.mdx | 65 +--
docs/content/docs/bridges/herald.mdx | 11 +-
docs/content/docs/bridges/keysmith.mdx | 35 +-
docs/content/docs/bridges/ledger.mdx | 17 +-
docs/content/docs/bridges/mailer.mdx | 95 ++---
docs/content/docs/bridges/metrics.mdx | 9 +-
docs/content/docs/bridges/overview.mdx | 125 +++---
docs/content/docs/bridges/relay.mdx | 15 +-
docs/content/docs/bridges/sms.mdx | 76 ++--
docs/content/docs/bridges/vault.mdx | 58 ++-
docs/content/docs/bridges/warden.mdx | 108 +----
docs/content/docs/concepts/configuration.mdx | 152 ++++---
docs/content/docs/concepts/entities.mdx | 279 +++++++------
docs/content/docs/concepts/errors.mdx | 65 ++-
docs/content/docs/concepts/identity.mdx | 66 ++--
docs/content/docs/concepts/multi-tenancy.mdx | 85 ++--
docs/content/docs/dynamic-forms/branding.mdx | 16 +-
.../docs/dynamic-forms/custom-schemas.mdx | 31 +-
.../docs/dynamic-forms/form-editor.mdx | 12 +-
docs/content/docs/environments/cloning.mdx | 177 ++-------
docs/content/docs/environments/overview.mdx | 269 ++++---------
.../docs/environments/per-env-isolation.mdx | 195 ++-------
docs/content/docs/getting-started.mdx | 332 ++++++++--------
docs/content/docs/guides/custom-plugin.mdx | 14 +-
docs/content/docs/guides/custom-store.mdx | 169 +++++---
docs/content/docs/guides/forge-extension.mdx | 294 +++++++-------
docs/content/docs/guides/full-example.mdx | 372 ++++++++++--------
docs/content/docs/guides/migration-guide.mdx | 3 +-
docs/content/docs/index.mdx | 98 ++---
.../docs/organizations/invitations.mdx | 250 +++---------
docs/content/docs/organizations/members.mdx | 196 ++-------
.../docs/organizations/org-scoped-roles.mdx | 243 ++----------
docs/content/docs/organizations/overview.mdx | 255 +++---------
docs/content/docs/organizations/teams.mdx | 227 ++---------
docs/content/docs/plugins/apikey.mdx | 25 +-
docs/content/docs/plugins/consent.mdx | 31 +-
.../content/docs/plugins/creating-plugins.mdx | 6 +-
docs/content/docs/plugins/device.mdx | 54 ++-
docs/content/docs/plugins/email.mdx | 13 +-
docs/content/docs/plugins/magiclink.mdx | 21 +-
docs/content/docs/plugins/mfa.mdx | 49 +--
docs/content/docs/plugins/notification.mdx | 1 +
docs/content/docs/plugins/oauth2provider.mdx | 41 +-
docs/content/docs/plugins/organization.mdx | 39 +-
docs/content/docs/plugins/overview.mdx | 21 +-
docs/content/docs/plugins/passkey.mdx | 27 +-
docs/content/docs/plugins/password.mdx | 16 +-
docs/content/docs/plugins/phone.mdx | 11 +-
docs/content/docs/plugins/social.mdx | 50 +--
docs/content/docs/plugins/sso.mdx | 42 +-
docs/content/docs/security/data-retention.mdx | 2 +-
docs/content/docs/security/rate-limiting.mdx | 5 +-
docs/content/docs/stores/memory.mdx | 217 +++-------
docs/content/docs/stores/mongodb.mdx | 172 +++-----
docs/content/docs/stores/overview.mdx | 81 ++--
docs/content/docs/stores/postgres.mdx | 231 +++--------
docs/content/docs/stores/sqlite.mdx | 119 ++----
docs/content/docs/ui/flutter.mdx | 2 +-
docs/content/docs/ui/nextjs.mdx | 14 +-
docs/content/docs/ui/overview.mdx | 36 +-
.../docs/users-sessions/device-tracking.mdx | 57 +--
.../docs/users-sessions/impersonation.mdx | 17 +-
.../docs/users-sessions/session-lifecycle.mdx | 15 +-
.../docs/users-sessions/token-formats.mdx | 80 ++--
.../docs/users-sessions/user-management.mdx | 71 ++--
plugins/README.md | 7 +-
79 files changed, 2829 insertions(+), 4296 deletions(-)
diff --git a/docs/content/docs/api-reference/go-packages.mdx b/docs/content/docs/api-reference/go-packages.mdx
index 6669032a..486d0c10 100644
--- a/docs/content/docs/api-reference/go-packages.mdx
+++ b/docs/content/docs/api-reference/go-packages.mdx
@@ -13,29 +13,27 @@ Root package. Exports the engine constructor, configuration types, and functiona
| Export | Description |
|--------|-------------|
-| `New(...Option) (*Engine, error)` | Create an Authsome engine |
-| `Config` | Engine configuration struct |
-| `PasswordConfig` | Password policy configuration |
-| `SessionConfig` | Session behavior configuration |
-| `MFAConfig` | Multi-factor authentication configuration |
-| `PhoneConfig` | Phone authentication configuration |
-| `OAuth2Config` | OAuth2 provider configuration |
-| `JWTConfig` | JWT signing configuration |
-| `RateLimitConfig` | Rate limiting configuration |
+| `NewEngine(...Option) (*Engine, error)` | Create an Authsome engine (needs `WithStore`, `WithChronicle` and `WithWarden`) |
+| `Config`, `DefaultConfig()` | Engine configuration and its defaults |
+| `PasswordConfig`, `SessionConfig`, `RateLimitConfig`, `LockoutConfig` | Sections of `Config` |
+| `RetentionConfig`, `WebhookConfig`, `CSRFConfig`, `TokenEncryptionConfig` | More sections of `Config` |
| `WithStore(store.Store)` | Set the primary data store |
-| `WithPlugin(Plugin)` | Register an authentication plugin |
+| `WithChronicle(bridge.Chronicle)`, `WithWarden(*warden.Engine)` | The two required bridges |
+| `WithDriverName(string)`, `WithDB(*grove.DB)` | Plugin migrations and plugin storage |
+| `WithPlugin(Plugin)` | Register a plugin |
| `WithConfig(Config)` | Set engine configuration |
-| `WithMFAStore(mfa.Store)` | Set the MFA enrollment store |
+| `WithBootstrap(...BootstrapOption)` | Create the platform app, environments and roles on start |
+| `WithTokenEncryptor(bridge.Encryptor)` | Encrypt stored tokens with your own encryptor |
| `WithSMSSender(bridge.SMSSender)` | Set the SMS bridge |
| `WithMailer(bridge.Mailer)` | Set the email bridge |
| `WithPasswordHistory(store)` | Set the password history store |
| `WithRateLimiter(ratelimit.Limiter)` | Set the rate limiter |
-| `WithLockout(lockout.Lockout)` | Set the account lockout handler |
+| `WithLockoutTracker(lockout.Tracker)` | Set the account lockout tracker |
| `WithLogger(logger)` | Set the logger |
-### `github.com/xraph/authsome/engine`
+### `*authsome.Engine` methods
-The central engine with all authentication and management operations.
+`Engine` lives in the root package; there's no separate `engine` package. Some of its methods:
| Export | Description |
|--------|-------------|
@@ -44,7 +42,7 @@ The central engine with all authentication and management operations.
| `Engine.SignUp(ctx, req)` | Create user account and session |
| `Engine.SignIn(ctx, req)` | Authenticate user |
| `Engine.SignOut(ctx, sessionID)` | Revoke a session |
-| `Engine.Refresh(ctx, refreshToken)` | Refresh session tokens |
+| `Engine.Refresh(ctx, refreshToken, ...RefreshOpts)` | Refresh session tokens |
| `Engine.GetMe(ctx, userID)` | Get user profile |
| `Engine.UpdateMe(ctx, user)` | Update user profile |
| `Engine.DeleteAccount(ctx, userID)` | Delete user account (GDPR) |
@@ -53,11 +51,13 @@ The central engine with all authentication and management operations.
| `Engine.ResetPassword(ctx, token, newPassword)` | Complete password reset |
| `Engine.ChangePassword(ctx, userID, current, new)` | Change password |
| `Engine.VerifyEmail(ctx, token)` | Verify email address |
-| `Engine.Impersonate(ctx, adminID, targetID, appID)` | Create impersonation session |
+| `Engine.Impersonate(ctx, adminID, targetID, ...ImpersonateOption)` | Create an impersonation session |
| `Engine.ListSessions(ctx, userID)` | List user sessions |
| `Engine.RevokeSession(ctx, sessionID)` | Revoke a specific session |
-| `Engine.RevokeAllSessions(ctx, userID)` | Revoke all user sessions |
-| `Engine.ValidateToken(ctx, token)` | Validate a session token |
+| `Engine.RevokeOtherUserSessions(ctx, userID, keep)` | Revoke a user's other sessions |
+| `Engine.RevokeUserAccess(ctx, userID)` | Revoke all of a user's access |
+| `Engine.ResolveSessionByToken(ctx, token)` | Look up the session behind a token |
+| `Engine.ValidateJWT(token)` | Validate a JWT access token |
| `Engine.ListUserDevices(ctx, userID)` | List tracked devices |
| `Engine.GetDevice(ctx, deviceID)` | Get device by ID |
| `Engine.DeleteDevice(ctx, deviceID)` | Remove a device |
diff --git a/docs/content/docs/api-reference/http-api.mdx b/docs/content/docs/api-reference/http-api.mdx
index d4c317eb..f6696f43 100644
--- a/docs/content/docs/api-reference/http-api.mdx
+++ b/docs/content/docs/api-reference/http-api.mdx
@@ -1,20 +1,20 @@
---
title: HTTP API Reference
-description: Complete reference for all Authsome REST endpoints — authentication, users, sessions, MFA, devices, organizations, RBAC, webhooks, and OAuth2.
+description: Complete reference for all Authsome REST endpoints: authentication, users, sessions, MFA, devices, organizations, RBAC, webhooks, and OAuth2.
---
-All endpoints are registered under the configurable `BasePath` (default `/v1/auth`) and return JSON. Authentication is via `Authorization: Bearer ` header unless noted otherwise. Tenant resolution depends on your Forge middleware configuration.
+Paths on this page are relative to `/v1`. Standalone, the API is mounted at `/v1`; with the Forge extension it's under `/v1`, which is `/authsome/v1` by default. Plugin routes exist only when the plugin is registered (and, standalone, when you mount its routes). Everything returns JSON. Authentication is via `Authorization: Bearer ` header unless noted otherwise. Tenant resolution depends on your Forge middleware configuration.
## App context resolution
Every public-auth endpoint (`/signup`, `/signin`, `/forgot-password`, `/verify-email/resend`) needs to know **which app** the request belongs to. Send one of:
-- **`X-Publishable-Key: pk_live_…` header** — recommended for frontends. The server resolves the publishable key to its owning app and routes the request there. The official SDKs (`@authsome/sdk` for TypeScript, `authclient` for Go, `authsome` for Dart) accept a `publishableKey` config option and stamp this header on every request automatically.
-- **`app_id` body field** — for server-to-server callers that don't carry a publishable key.
+- `X-Publishable-Key: pk_live_…` header: recommended for frontends. The server resolves the publishable key to its owning app and routes the request there. The official SDKs (`@authsome/client` for TypeScript, `authclient` for Go, `authsome` for Dart) accept a `publishableKey` config option and stamp this header on every request automatically.
+- `app_id` body field: for server-to-server callers that don't carry a publishable key.
If both are sent, the publishable key wins; a body `app_id` that disagrees with the resolved key is rejected with `400 app_id does not match publishable key` to fail closed on misconfigured callers.
-If neither is sent, the endpoint returns `400 app context required: send X-Publishable-Key header or app_id in the body`. There is no longer a silent fallback to the platform app — that fallback used to mis-route non-platform tenants' users into the platform pool.
+If neither is sent, the endpoint returns `400 app context required: send X-Publishable-Key header or app_id in the body`. There is no longer a silent fallback to the platform app: that fallback used to mis-route non-platform tenants' users into the platform pool.
## Authentication
@@ -28,10 +28,12 @@ Create a user account and return a session.
{
"email": "string (required)",
"password": "string (required)",
- "name": "string",
+ "first_name": "string",
+ "last_name": "string",
"username": "string",
"app_id": "string (required if no X-Publishable-Key header)",
- "metadata": {}
+ "metadata": {},
+ "captcha_token": "string (when the app requires captcha)"
}
```
@@ -45,7 +47,8 @@ Send `X-Publishable-Key: pk_*` instead of `app_id` to let the server resolve the
"id": "ausr_01j9...",
"email": "alice@example.com",
"username": "alice",
- "name": "Alice Liddell",
+ "first_name": "Alice",
+ "last_name": "Liddell",
"email_verified": false,
"app_id": "aapp_01j9...",
"metadata": {},
@@ -74,9 +77,9 @@ Authenticate with email/username and password.
}
```
-One of `email` or `username` is required. Send `X-Publishable-Key` to let the server resolve the app — see [App context resolution](#app-context-resolution).
+One of `email` or `username` is required. Send `X-Publishable-Key` to let the server resolve the app: see [App context resolution](#app-context-resolution).
-**Response** `200 OK` — same structure as signup response.
+**Response** `200 OK`: same structure as signup response.
If MFA is enabled for the user, returns an MFA challenge instead:
@@ -218,7 +221,7 @@ Verify email address with a verification token.
Return the authenticated user's profile. Requires `Authorization` header.
-**Response** `200 OK` — User object.
+**Response** `200 OK`: User object.
---
@@ -236,7 +239,7 @@ Update the authenticated user's profile fields. Requires `Authorization` header.
}
```
-**Response** `200 OK` — Updated User object.
+**Response** `200 OK`: Updated User object.
---
@@ -256,7 +259,7 @@ Permanently delete the authenticated user's account (GDPR right to erasure). Req
Export all data associated with the authenticated user (GDPR data portability). Requires `Authorization` header.
-**Response** `200 OK` — JSON object containing user, sessions, devices, organizations, and MFA enrollments.
+**Response** `200 OK`: JSON object containing user, sessions, devices, organizations, and MFA enrollments.
---
@@ -314,7 +317,7 @@ Start MFA enrollment for a method. Requires `Authorization` header.
}
```
-**Response** `201 Created` — Enrollment object with TOTP URI/secret (for TOTP) and recovery codes.
+**Response** `201 Created`: Enrollment object with TOTP URI/secret (for TOTP) and recovery codes.
---
@@ -331,7 +334,7 @@ Verify MFA enrollment with a code. Requires `Authorization` header.
}
```
-**Response** `200 OK` — Updated enrollment with `verified: true`.
+**Response** `200 OK`: Updated enrollment with `verified: true`.
---
@@ -349,7 +352,7 @@ Complete an MFA challenge during sign-in.
}
```
-**Response** `200 OK` — User object with session tokens.
+**Response** `200 OK`: User object with session tokens.
---
@@ -361,25 +364,23 @@ Remove MFA enrollment and recovery codes. Requires `Authorization` header.
---
-### `POST /mfa/recovery-codes/regenerate`
+### `POST /mfa/recovery/regenerate`
Generate new recovery codes (invalidates old ones). Requires `Authorization` header.
-**Response** `200 OK` — Array of plaintext recovery codes.
+**Response** `200 OK`: Array of plaintext recovery codes.
---
-### `GET /mfa/enrollments`
+### Other MFA routes
-List active MFA enrollments. Requires `Authorization` header.
-
-**Response** `200 OK` — Array of Enrollment objects.
+`POST /mfa/sms/send` and `POST /mfa/sms/verify` handle SMS codes, and `POST /mfa/recovery/verify` signs in with a recovery code. There's no route that lists enrollments; `GET /me/auth-methods` shows whether MFA is set up.
---
## Phone Authentication
-### `POST /phone/send-code`
+### `POST /phone/start`
Request an SMS OTP for phone sign-in.
@@ -414,7 +415,7 @@ Verify OTP and sign in (or sign up if new user).
}
```
-**Response** `200 OK` — User object with session tokens.
+**Response** `200 OK`: User object with session tokens.
---
@@ -461,7 +462,7 @@ Remove a tracked device.
Mark a device as trusted.
-**Response** `200 OK` — Updated Device object with `trusted: true`.
+**Response** `200 OK`: Updated Device object with `trusted: true`.
---
@@ -483,7 +484,7 @@ Create a new RBAC role.
}
```
-**Response** `201 Created` — Role object.
+**Response** `201 Created`: Role object.
---
@@ -528,7 +529,7 @@ Add a permission (action + resource) to a role.
}
```
-**Response** `201 Created` — Permission object.
+**Response** `201 Created`: Permission object.
---
@@ -595,7 +596,7 @@ Register a new webhook endpoint.
}
```
-**Response** `201 Created` — Webhook object including a generated `secret` for signature verification.
+**Response** `201 Created`: Webhook object including a generated `secret` for signature verification.
---
@@ -639,7 +640,7 @@ Delete a webhook registration.
## Organizations
-### `POST /organizations`
+### `POST /orgs`
Create a new organization.
@@ -656,73 +657,73 @@ Create a new organization.
---
-### `GET /organizations`
+### `GET /orgs`
List organizations for an app.
---
-### `GET /organizations/:orgId`
+### `GET /orgs/:orgId`
Get organization details.
---
-### `PATCH /organizations/:orgId`
+### `PATCH /orgs/:orgId`
Update an organization.
---
-### `DELETE /organizations/:orgId`
+### `DELETE /orgs/:orgId`
Delete an organization.
---
-### `POST /organizations/:orgId/members`
+### `POST /orgs/:orgId/members`
Add a member to an organization.
---
-### `GET /organizations/:orgId/members`
+### `GET /orgs/:orgId/members`
List members of an organization.
---
-### `PATCH /organizations/:orgId/members/:memberId`
+### `PATCH /orgs/:orgId/members/:memberId`
Update a member's role.
---
-### `DELETE /organizations/:orgId/members/:memberId`
+### `DELETE /orgs/:orgId/members/:memberId`
Remove a member from an organization.
---
-### `POST /organizations/:orgId/invitations`
+### `POST /orgs/:orgId/invitations`
Create an invitation.
---
-### `GET /organizations/:orgId/invitations`
+### `GET /orgs/:orgId/invitations`
List invitations for an organization.
---
-### `POST /organizations/:orgId/teams`
+### `POST /orgs/:orgId/teams`
Create a team within an organization.
---
-### `GET /organizations/:orgId/teams`
+### `GET /orgs/:orgId/teams`
List teams.
@@ -730,7 +731,7 @@ List teams.
## OAuth2 Provider
-### `GET /oauth2/authorize`
+### `GET /oauth/authorize`
Authorization endpoint. Redirects the user through the login/consent flow.
@@ -746,7 +747,7 @@ Authorization endpoint. Redirects the user through the login/consent flow.
---
-### `POST /oauth2/token`
+### `POST /oauth/token`
Token endpoint for code exchange, client credentials, and refresh.
@@ -770,43 +771,43 @@ grant_type=refresh_token&refresh_token=...&client_id=...
---
-### `POST /oauth2/introspect`
+### `POST /introspect`
Token introspection. Requires client authentication.
---
-### `POST /oauth2/revoke`
+### `POST /oauth/revoke`
Token revocation. Always returns `200 OK`.
---
-### `POST /oauth2/clients`
+### `POST /admin/oauth/clients`
Register a new OAuth2 client.
---
-### `GET /oauth2/clients`
+### `GET /admin/oauth/clients`
List registered clients.
---
-### `GET /oauth2/clients/:clientId`
+### `GET /admin/oauth/clients/:clientId`
Get client details.
---
-### `PATCH /oauth2/clients/:clientId`
+### `PATCH /admin/oauth/clients/:clientId`
Update a client.
---
-### `DELETE /oauth2/clients/:clientId`
+### `DELETE /admin/oauth/clients/:clientId`
Delete a client.
@@ -828,6 +829,10 @@ OpenID Connect discovery document.
## Forms & Branding
+
+These routes don't exist. Form configs and branding are managed from Go (`SaveSignupFormConfig`, `SaveOrgBranding` and their siblings) or the Forge dashboard. See [Dynamic Forms](/docs/dynamic-forms/custom-schemas).
+
+
### `POST /forms`
Create a form configuration.
@@ -889,40 +894,33 @@ Delete a branding configuration.
## Admin
-### `POST /admin/impersonate`
+### `POST /admin/impersonate/:userId`
-Start an impersonation session. Requires admin role.
+Start an impersonation session for the user in the path. Requires admin role. No body. `POST /admin/impersonate/stop` ends it.
-**Request**
-
-```json
-{
- "target_user_id": "string (required)",
- "app_id": "string"
-}
-```
+The admin API has many more routes under `/admin` (users, bulk operations, apps, client configs, settings, OAuth clients, SSO connections); the OpenAPI document at `/.well-known/authsome/openapi` lists them all.
-**Response** `200 OK` — Session tokens for the target user with `impersonated_by` field set.
+**Response** `200 OK`: Session tokens for the target user with `impersonated_by` field set.
---
## Error format
-All error responses use a consistent JSON envelope:
+Errors carry the HTTP status in `code` and a message in `error`. Errors a client has to tell apart add a `type`:
```json
-{
- "error": "human-readable message",
- "code": "ERROR_CODE"
-}
+{"code": 403, "error": "please verify your email address before signing in", "type": "email_not_verified"}
```
-| HTTP status | `code` | Cause |
-|-------------|--------|-------|
-| `400` | `BAD_REQUEST` | Missing required field, validation error, weak password |
-| `401` | `UNAUTHORIZED` | Invalid credentials, expired session, missing auth header |
-| `403` | `FORBIDDEN` | User is banned, insufficient permissions |
-| `404` | `NOT_FOUND` | User, session, device, webhook, or role not found |
-| `409` | `CONFLICT` | Email already taken, username already taken |
-| `429` | `RATE_LIMITED` | Too many requests (rate limit exceeded) |
-| `500` | `INTERNAL_ERROR` | Store failure, unexpected server error |
+| HTTP status | Cause |
+|-------------|-------|
+| `400` | Missing or invalid field, weak password |
+| `401` | Invalid credentials, expired session, missing auth header |
+| `403` | Banned user, insufficient permissions, `mfa_required` (with `mfa_ticket`), `email_not_verified` |
+| `404` | Not found |
+| `409` | Email or username taken, other conflicts |
+| `423` | `account_locked`, with `retry_after` |
+| `429` | `rate_limited`, too many verification attempts |
+| `500` | Store failure or unexpected error |
+
+[Errors](/docs/concepts/errors) maps each Go error to its status.
diff --git a/docs/content/docs/architecture.mdx b/docs/content/docs/architecture.mdx
index 56582139..2eec9a71 100644
--- a/docs/content/docs/architecture.mdx
+++ b/docs/content/docs/architecture.mdx
@@ -59,16 +59,16 @@ The `Engine` is the top-level orchestrator. It holds references to the store, pl
```go
import "github.com/xraph/authsome"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(myStore),
authsome.WithPlugin(passwordPlugin),
- authsome.WithWarden(wardenEngine), // optional: Warden for RBAC
- authsome.WithChronicle(chronicle), // optional: audit trail
+ authsome.WithWarden(wardenEngine), // required: authorization
+ authsome.WithChronicle(chronicle), // required: audit trail
authsome.WithMailer(mailer), // optional: transactional email
)
```
-The engine is constructed with `authsome.New(opts...)` and activated with `eng.Start(ctx)`. It exposes the full service API as methods on `*Engine`.
+The engine is constructed with `authsome.NewEngine(opts...)` and activated with `eng.Start(ctx)`. It exposes the full service API as methods on `*Engine`.
## Store abstraction
@@ -76,41 +76,24 @@ All persistence goes through the `store.Store` composite interface. Swap the bac
```go
// Development: no external dependencies
-store := memory.New()
+st := memory.New()
-// Production: PostgreSQL
-store, err := postgres.New(ctx, "postgres://user:pass@host:5432/db")
+// Production: PostgreSQL, on a Grove database opened with pgdriver
+st := postgres.New(groveDB)
-// Alternative: SQLite for embedded applications
-store, err := sqlite.New(ctx, "./auth.db")
-
-// Alternative: MongoDB for document-oriented workloads
-store, err := mongo.New(ctx, "mongodb://localhost:27017", "authdb")
+// SQLite (sqlitedriver) or MongoDB (mongodriver) work the same way
+st := sqlite.New(groveDB)
+st := mongo.New(groveDB)
```
-The composite interface covers:
-
-| Sub-interface | Responsibility |
-|---------------|----------------|
-| `UserStore` | User CRUD, lookup by email/username/phone |
-| `SessionStore` | Session CRUD, lookup by token/refresh token |
-| `DeviceStore` | Device fingerprinting and trusted device management |
-| `WebhookStore` | Webhook endpoint registration and delivery records |
-| `OrganizationStore` | Orgs, members, teams, invitations |
-| `EnvironmentStore` | Environment isolation per app |
-| `RBACStore` | Roles, permissions, user role assignments |
-| `FormConfigStore` | Dynamic form field definitions |
-| `AppSessionConfigStore` | Per-app session configuration overrides |
-| `PasswordResetStore` | Password reset tokens |
-| `VerificationStore` | Email/phone verification tokens |
-| `PasswordHistoryStore` | Historic password hashes for reuse prevention |
+Each SQL or Mongo store takes a `*grove.DB`; [Store Backends](/docs/stores/overview) shows how to open one and lists the domain stores that `store.Store` is made of (users, sessions, accounts, apps, organizations, devices, webhooks, notifications, API keys, environments, form and branding configs, per-app session and client configs, settings, service accounts, principals, KV, retention and paging).
## Plugin system
Plugins are the primary extension point. Each plugin implements the `plugin.Plugin` base interface and any combination of optional lifecycle interfaces.
```go
-// Base interface — all plugins must implement this.
+// Base interface: all plugins must implement this.
type Plugin interface {
Name() string
}
@@ -193,30 +176,32 @@ The hook bus defines 40+ named action constants:
## Strategy registry
-Authentication strategies are registered with a priority. When a sign-in request arrives, the engine selects the highest-priority strategy that accepts the request.
+Authentication strategies are registered with a priority, and the registry tries them in priority order.
```go
import "github.com/xraph/authsome/strategy"
-// Plugins register strategies through the plugin.AuthMethodContributor interface.
-// You can also register strategies directly:
-eng.RegisterStrategy(myStrategy, 5)
+// Plugins register their strategies themselves. To add your own:
+authsome.WithStrategy(myStrategy, 5)
+
+// or, on a built engine:
+eng.Strategies().Register(myStrategy, 5)
```
Lower priority values are evaluated first (priority 1 runs before priority 10). This allows you to insert custom strategies that intercept before built-in ones.
## Bridges
-Bridges are optional integration interfaces. When a bridge is not configured, the corresponding functionality is silently skipped (fail-open).
+Bridges are the interfaces Authsome uses to reach other systems. Chronicle and Warden are required: `NewEngine` returns an error without them. The rest are optional, and when one isn't configured Authsome uses a no-op and skips that work.
| Bridge | Interface | Used for |
|--------|-----------|----------|
-| `Chronicle` | `bridge.Chronicle` | Audit trail — records every auth event with severity and outcome |
-| `Relay` | `bridge.EventRelay` | Webhook delivery — forwards hook events to registered endpoints |
+| `Chronicle` | `bridge.Chronicle` | Audit trail: records every auth event with severity and outcome |
+| `Relay` | `bridge.EventRelay` | Webhook delivery: forwards hook events to registered endpoints |
| `Mailer` | `bridge.Mailer` | Transactional email for magic links, verifications, password resets |
| `SMSSender` | `bridge.SMSSender` | SMS delivery for phone OTP and MFA SMS codes |
| `Herald` | `bridge.Herald` | Unified multi-channel notification system (replaces Mailer + SMS) |
-| `Warden` | `bridge.Authorizer` | Authorization engine for RBAC/ReBAC/ABAC policy evaluation |
+| `Warden` | `*warden.Engine` (`WithWarden`), or a `bridge.Authorizer` (`WithAuthorizer`) | Authorization for RBAC/ReBAC/ABAC policy evaluation |
| `Keysmith` | `bridge.KeyManager` | API key management with rotation, scopes, and usage tracking |
| `Vault` | `bridge.Vault` | Secrets management and feature flag access |
| `Dispatch` | `bridge.Dispatcher` | Background job queue for async tasks |
@@ -239,11 +224,11 @@ import (
authsome.WithChronicle(chronicleadapter.New(chronicleEngine))
// Mailer: SMTP or Resend
-authsome.WithMailer(maileradapter.NewSMTP(smtpConfig))
-authsome.WithMailer(maileradapter.NewResend(resendAPIKey))
+authsome.WithMailer(maileradapter.NewSMTPMailer("smtp.example.com", "587", user, pass, "noreply@example.com"))
+authsome.WithMailer(maileradapter.NewResendMailer(resendAPIKey, "noreply@example.com"))
// SMS: Twilio
-authsome.WithSMSSender(smsadapter.NewTwilio(twilioConfig))
+authsome.WithSMSSender(smsadapter.NewTwilioSender(accountSID, authToken, "+15550001111"))
// Relay: wrap a Relay engine
authsome.WithEventRelay(relayadapter.New(relayEngine))
@@ -251,33 +236,46 @@ authsome.WithEventRelay(relayadapter.New(relayEngine))
## API layer and middleware
-The engine integrates with Forge-based HTTP applications through the extension system.
+The HTTP API is a set of Forge routes. In a Forge app, register the extension and it mounts them for you:
```go
-import "github.com/xraph/authsome/extension"
+import authsomeext "github.com/xraph/authsome/extension"
-// Register as a Forge extension (auto-wires store from DI container).
-app.Register(extension.New(
- extension.WithPlugin(password.New()),
- extension.WithPlugin(social.New(socialCfg)),
+app.RegisterExtension(authsomeext.New(
+ authsomeext.WithPlugin(password.New()),
+ authsomeext.WithPlugin(social.New(socialCfg)),
))
```
-Standalone middleware is available for non-Forge applications:
+Without the extension, mount the API on a Forge router yourself, with the auth middleware in front:
```go
-import "github.com/xraph/authsome/middleware"
+router := forge.NewRouter()
+router.Use(middleware.AuthMiddleware(eng.ResolveSessionByToken, eng.ResolveUser, nil))
+if err := api.New(eng).RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+}
+for _, rp := range eng.Plugins().RouteProviders() { // plugin routes
+ if err := rp.RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+ }
+}
+http.ListenAndServe(":8080", router.Handler())
+```
-// Extract and validate Bearer token, inject user into context.
-mux.Handle("/api/", middleware.RequireAuth(eng)(apiHandler))
+The `middleware` package also has guards for your own routes, all `forge.Middleware`:
-// Require a specific permission (delegates to Warden if configured).
-mux.Handle("/api/admin/", middleware.RequirePermission(eng, "admin", "users")(adminHandler))
+```go
+// Reject requests without an authenticated user.
+router.GET("/api/me", handler, forge.WithMiddleware(middleware.RequireAuth()))
-// Apply per-endpoint rate limiting.
-mux.Handle("/api/", middleware.RateLimit(eng)(apiHandler))
+// Require a permission. The engine implements PermissionChecker through Warden.
+router.GET("/api/admin/users", adminHandler,
+ forge.WithMiddleware(middleware.RequirePermission(eng, "read", "users")))
```
+`middleware.RateLimit(limiter, cfg)`, `RequireScope`, `RequireAnyRole`, `CSRF` and `SecurityHeaders` are there too.
+
## Token formats
Access tokens are opaque 64-character hex strings by default. Switch to signed JWTs per app for use cases that require client-side validation or OIDC compatibility.
@@ -285,17 +283,18 @@ Access tokens are opaque 64-character hex strings by default. Switch to signed J
```go
import "github.com/xraph/authsome/tokenformat"
-// JWT format with RSA-256 signing key.
+// JWT format with an RS256 signing key (jwt is github.com/golang-jwt/jwt/v5).
jwtFmt, err := tokenformat.NewJWT(tokenformat.JWTConfig{
- Algorithm: "RS256",
- PrivateKey: rsaPrivateKey,
- PublicKey: rsaPublicKey,
- Issuer: "https://auth.myapp.com",
- Audience: []string{"https://api.myapp.com"},
+ SigningMethod: jwt.SigningMethodRS256,
+ SigningKey: rsaPrivateKey,
+ VerifyKey: &rsaPrivateKey.PublicKey,
+ KeyID: "key-1",
+ Issuer: "https://auth.myapp.com",
+ Audience: "https://api.myapp.com",
})
-// Register JWT format for a specific app.
-authsome.WithJWTFormat("myapp", jwtFmt)
+// Use JWTs for one app.
+authsome.WithJWTFormat(appID.String(), jwtFmt)
```
When JWT is configured for an app, all sessions created for that app receive a signed JWT as the access token. The refresh token remains opaque.
@@ -304,7 +303,7 @@ When JWT is configured for an app, all sessions created for that app receive a s
| Package | Import path | Purpose |
|---------|-------------|---------|
-| `authsome` | `github.com/xraph/authsome` | Root — Engine, Config, Option, entity type aliases |
+| `authsome` | `github.com/xraph/authsome` | Root: Engine, Config, Option, entity type aliases |
| `id` | `github.com/xraph/authsome/id` | TypeID-based identifiers with 27 prefixes |
| `account` | `github.com/xraph/authsome/account` | SignUpRequest, SignInRequest, session creation, password hashing |
| `user` | `github.com/xraph/authsome/user` | User entity and UserQuery/UserList pagination types |
diff --git a/docs/content/docs/authentication/magic-links.mdx b/docs/content/docs/authentication/magic-links.mdx
index c2a94bbb..48f97aa4 100644
--- a/docs/content/docs/authentication/magic-links.mdx
+++ b/docs/content/docs/authentication/magic-links.mdx
@@ -3,7 +3,7 @@ title: Magic Links
description: Passwordless email sign-in with time-limited magic link tokens.
---
-The `magiclink` plugin provides passwordless sign-in via a one-time link sent to the user's email address. No password is required. Users click the link and are immediately authenticated with a full session.
+The `magiclink` plugin signs users in with a one-time link sent to their email address. They click the link and get a session; no password involved.
Magic links are ideal for:
- Applications where users sign in infrequently (reducing password friction)
@@ -14,137 +14,86 @@ Magic links are ideal for:
```go
import (
+ "context"
+
"github.com/xraph/authsome"
"github.com/xraph/authsome/plugins/magiclink"
- "github.com/xraph/authsome/bridge/maileradapter"
)
-mailer := maileradapter.NewResend("re_your_resend_api_key")
-
-eng, err := authsome.New(
- authsome.WithStore(store),
- authsome.WithMailer(mailer), // required: magic links are sent via email
+eng, err := authsome.NewEngine(
+ // plus WithStore, WithChronicle and WithWarden (see Getting Started)
authsome.WithPlugin(magiclink.New(magiclink.Config{
- TokenTTL: 15 * time.Minute, // how long the link stays valid
- AutoCreate: true, // create user on first sign-in if not found
+ Mailer: magiclink.MailerFunc(func(ctx context.Context, email, token string) error {
+ link := "https://myapp.com/auth/magic?token=" + token
+ return sendLoginEmail(ctx, email, link) // your email code
+ }),
})),
)
```
-A `Mailer` bridge is required. If no mailer is configured, magic link requests succeed on the server side (the token is generated) but the email is never delivered. Always configure a mailer before deploying to production.
+The plugin sends only through `Config.Mailer`. It doesn't fall back to the engine's mailer bridge (`WithMailer`). Without a `Mailer`, `/send` still creates the token and answers success, but no email goes out.
-## How it works
-
-
-
-**Request a magic link**: The user submits their email address.
-
-`POST /v1/auth/magic-link/request`
-
-```json
-{"email": "alice@example.com", "app_id": "myapp"}
-```
+`magiclink.Mailer` is one method, `SendMagicLink(ctx, email, token string) error`, and `MailerFunc` adapts a plain function. You build the link, so its URL and the email's wording are yours.
-The engine:
-1. Looks up the user by email. If `AutoCreate: true` and the user does not exist, creates a new user account automatically.
-2. Generates a cryptographically random token.
-3. Stores the token with a TTL (default 15 minutes).
-4. Calls the `Mailer` bridge to deliver the email.
-
-The response is always `200 OK` regardless of whether the email exists, preventing email enumeration.
-
-
-**User clicks the link**: Your application receives the request with the token in the URL.
-
-The link format is determined by your application. A typical pattern:
-
-```
-https://myapp.com/auth/magic-link?token=ml_abc123xyz&app_id=myapp
-```
+## How it works
-Your frontend extracts the token and calls the confirm endpoint.
+1. The user submits their email address:
+ `POST /v1/magic-link/send`
-**Confirm the token**: Exchange the token for a session.
+ ```json
+ {"email": "alice@example.com", "app_id": "aapp_..."}
+ ```
-`POST /v1/auth/magic-link/confirm`
+ If the address belongs to an account, the plugin creates a single-use token with the configured TTL and hands it to your `Mailer`. The response is `{"status": "magic link sent"}` either way, so the endpoint doesn't reveal which addresses exist. It doesn't create accounts for unknown addresses.
-```json
-{"token": "ml_abc123xyz", "app_id": "myapp"}
-```
+2. The user opens the link, and your frontend sends the token:
-On success, a full user + session pair is returned:
+ `POST /v1/magic-link/verify`
-```json
-{
- "user": {"id": "ausr_01j9...", "email": "alice@example.com", ...},
- "session": {"token": "a3f8c9d4...", "refresh_token": "d72b1ef8...", ...}
-}
-```
+ ```json
+ {"token": "...", "app_id": "aapp_..."}
+ ```
-The token is consumed (one-time use) and invalidated after use.
+ A valid, unexpired, unused token returns a session:
+ ```json
+ {
+ "user": {"id": "ausr_01j9...", "email": "alice@example.com"},
+ "session_token": "a3f8c9d4...",
+ "refresh_token": "d72b1ef8...",
+ "expires_at": "2026-10-14T18:29:39Z"
+ }
+ ```
+ The token is consumed, so a second request with it fails with 401.
## Configuration
```go
type Config struct {
- // TokenTTL is the lifetime of the magic link token (default: 15 minutes).
- TokenTTL time.Duration
-
- // AutoCreate creates a new user account if the email is not found.
- // Default: false — returns 404 for unknown emails.
- AutoCreate bool
-
- // RedirectURL is the base URL for the magic link.
- // The token is appended as a query parameter: RedirectURL + "?token=" + token.
- // Required if you want the plugin to construct the link automatically.
- RedirectURL string
+ Mailer Mailer // sends the token; nothing is sent without it
+ TokenTTL time.Duration // default 10 minutes
+ SessionTokenTTL time.Duration // default 1 hour
+ SessionRefreshTTL time.Duration // default 30 days
}
```
-## Email template customization
-
-The magic link email is sent through the `Mailer` bridge. To customize the email content, implement the `bridge.Mailer` interface and return your own HTML/text for the `magic_link` template type:
-
-```go
-type myMailer struct{}
-
-func (m *myMailer) Send(ctx context.Context, msg bridge.MailMessage) error {
- switch msg.Template {
- case "magic_link":
- // msg.Data["token"] contains the magic link token.
- // msg.Data["link"] contains the full redirect URL (if RedirectURL is configured).
- return sendCustomEmail(msg.To, msg.Data)
- }
- return nil
-}
-```
-
-The `bridge.MailMessage` struct contains:
-
-```go
-type MailMessage struct {
- To string // recipient email
- Template string // "magic_link", "verify_email", "reset_password"
- Subject string // suggested subject line
- Data map[string]string // template variables
-}
-```
+
+In a running engine the three durations come from the settings `magiclink.token_ttl_seconds` (600), `magiclink.session_token_ttl_seconds` (3600) and `magiclink.session_refresh_ttl_seconds` (2592000). Those settings have registered defaults, so the `Config` values are only used when no settings manager is available, which never happens inside an engine. To change a TTL, change the setting (globally or per app) through the settings API or the dashboard.
+
## Security considerations
-- **Tokens are single-use.** Once a token is consumed, it cannot be reused. Re-requesting a magic link generates a new token and invalidates all previous tokens for that email.
-- **Tokens expire.** The default TTL is 15 minutes. Set a shorter TTL for higher-security applications.
-- **Tokens are not guessable.** Tokens are generated using `crypto/rand` and are URL-safe base64-encoded.
-- **Rate limiting.** Use `RateLimitConfig.SignInLimit` to prevent abuse. Magic link requests that succeed server-side but fail email delivery are counted toward the rate limit.
+- Tokens are single-use and expire after the TTL.
+- `/verify` only accepts magic-link tokens, not other verification codes from the same table.
+- `/send` is rate limited like resend-verification (`RateLimitConfig.ResendVerificationLimit`) plus a per-address limit, and `/verify` like email verification (`VerifyEmailLimit`).
## API routes
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/magic-link/request` | Generate a magic link token and send email |
-| `POST` | `/magic-link/confirm` | Confirm the token and return a session |
+| `POST` | `/v1/magic-link/send` | Create a token and pass it to the `Mailer` |
+| `POST` | `/v1/magic-link/verify` | Exchange the token for a session |
diff --git a/docs/content/docs/authentication/mfa.mdx b/docs/content/docs/authentication/mfa.mdx
index 5ff95c01..77e9efea 100644
--- a/docs/content/docs/authentication/mfa.mdx
+++ b/docs/content/docs/authentication/mfa.mdx
@@ -3,37 +3,30 @@ title: Multi-Factor Authentication
description: Add TOTP, SMS OTP, and recovery codes as a second authentication factor with the MFA plugin.
---
-The `mfa` plugin adds a second authentication factor to any sign-in flow. It supports TOTP (Time-based One-Time Passwords) for authenticator apps, SMS OTP for phone-based verification, and one-time recovery codes as a fallback. MFA is enforced after primary authentication succeeds — the user signs in with their password (or other primary method), receives an MFA challenge, and must complete it before a session is issued.
+The `mfa` plugin adds a second authentication factor to any sign-in flow. It supports TOTP (Time-based One-Time Passwords) for authenticator apps, SMS OTP for phone-based verification, and one-time recovery codes as a fallback. MFA runs after primary authentication succeeds: the user signs in with their password (or other primary method), receives an MFA challenge, and must complete it before a session is issued.
## Setup
```go
import (
"github.com/xraph/authsome"
- "github.com/xraph/authsome/plugins/mfa"
"github.com/xraph/authsome/bridge/smsadapter"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/password"
)
-eng, err := authsome.New(
- authsome.WithStore(store),
+eng, err := authsome.NewEngine(
+ // plus WithStore, WithChronicle and WithWarden (see Getting Started)
+ authsome.WithDB(db), // where enrollments are stored; memory without it
authsome.WithPlugin(password.New()),
- authsome.WithMFAStore(mfaStore), // mfa.Store implementation
- authsome.WithSMSSender(smsSender), // required for SMS OTP
- authsome.WithConfig(authsome.Config{
- AppID: "myapp",
- MFA: authsome.MFAConfig{
- Enabled: true,
- Issuer: "MyApp", // shown in authenticator apps
- RecoveryCodeCount: 8, // number of backup codes
- SMSCodeLength: 6, // digits in SMS codes
- SMSCodeTTL: "5m", // SMS code validity window
- EnforceForAllUsers: false, // true = require MFA for every user
- },
- }),
+ authsome.WithPlugin(mfa.New(mfa.Config{
+ Issuer: "MyApp", // shown in authenticator apps; the mfa.issuer setting overrides it
+ })),
+ authsome.WithSMSSender(smsadapter.NewTwilioSender(sid, token, "+15559876543")), // for SMS codes
)
```
-> **Note:** The MFA plugin requires a dedicated `mfa.Store` for enrollment and recovery code persistence. All store backends (PostgreSQL, SQLite, MongoDB, memory) ship with MFA store implementations.
+`mfa.Config` has two fields, `Issuer` and `StepUpWindow`. The plugin stores enrollments and recovery codes itself: in the engine's database (PostgreSQL, SQLite or MongoDB, chosen from `WithDB`), wrapped in the token encryptor, or in memory when the engine has no database. Each enrollment gets 8 recovery codes. Whether MFA is required for an app is the `mfa_required` field of its client config.
## Concepts
@@ -57,7 +50,7 @@ The `Secret` field stores the Base32-encoded TOTP secret (for TOTP enrollments)
### Recovery codes
-Recovery codes are single-use backup codes that allow account access when the primary MFA device is unavailable. They are bcrypt-hashed before storage — only the plaintext codes are shown to the user once, at generation time.
+Recovery codes are single-use backup codes that allow account access when the primary MFA device is unavailable. They are bcrypt-hashed before storage: only the plaintext codes are shown to the user once, at generation time.
```go
type RecoveryCode struct {
@@ -70,7 +63,7 @@ type RecoveryCode struct {
}
```
-By default, 8 recovery codes are generated per enrollment. Each code is 8 characters long, drawn from an unambiguous alphabet (`abcdefghjkmnpqrstuvwxyz23456789` — no `0`, `o`, `1`, `l`, `i`).
+By default, 8 recovery codes are generated per enrollment. Each code is 8 characters long, drawn from an unambiguous alphabet (`abcdefghjkmnpqrstuvwxyz23456789`: no `0`, `o`, `1`, `l`, `i`).
## TOTP setup flow
@@ -82,7 +75,7 @@ Enrolling a factor is a step-up action. A user with no verified factor yet must
**Enroll**: Generate a TOTP secret and return the provisioning URI.
-`POST /v1/auth/mfa/enroll`
+`POST /v1/mfa/enroll`
```json
{
@@ -115,12 +108,12 @@ Enrolling a factor is a step-up action. A user with no verified factor yet must
}
```
-The `totp_uri` can be rendered as a QR code for the user to scan. The `recovery_codes` are shown exactly once — instruct the user to save them securely.
+The `totp_uri` can be rendered as a QR code for the user to scan. The `recovery_codes` are shown exactly once: instruct the user to save them securely.
**Verify**: Confirm enrollment by submitting a valid TOTP code from the authenticator app.
-`POST /v1/auth/mfa/verify`
+`POST /v1/mfa/verify`
```json
{
@@ -161,7 +154,7 @@ SMS OTP sends a 6-digit numeric code to the user's verified phone number. It req
**Enroll**: Register the user's phone number for SMS MFA.
-`POST /v1/auth/mfa/enroll`
+`POST /v1/mfa/enroll`
```json
{
@@ -175,7 +168,7 @@ The engine sends an initial verification code to confirm the phone number is val
**Verify**: Confirm enrollment by submitting the SMS code.
-`POST /v1/auth/mfa/verify`
+`POST /v1/mfa/verify`
```json
{
@@ -199,7 +192,8 @@ smsSender := smsadapter.NewTwilioSender(
"+15559876543", // From number
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithSMSSender(smsSender),
// ... other options
)
@@ -228,7 +222,7 @@ When MFA is enabled for a user, the sign-in flow becomes a two-phase process:
**Primary authentication**: The user signs in with email/password (or any primary method).
-`POST /v1/auth/signin`
+`POST /v1/signin`
```json
{
@@ -254,7 +248,7 @@ The `challenge_token` is a short-lived opaque token (default 5 minutes) that pro
**MFA verification**: The user submits the challenge token along with their MFA code.
-`POST /v1/auth/mfa/challenge`
+`POST /v1/mfa/challenge`
```json
{
@@ -271,7 +265,8 @@ On success, the engine creates and returns a full session:
"user": {
"id": "ausr_01j9...",
"email": "alice@example.com",
- "name": "Alice Liddell"
+ "first_name": "Alice",
+ "last_name": "Liddell"
},
"session_token": "a3f8c9d4...",
"refresh_token": "d72b1ef8...",
@@ -301,7 +296,7 @@ The recovery code is consumed (marked as `used`) on success. Each code can only
Users can regenerate their recovery codes at any time. This invalidates all previous codes.
-`POST /v1/auth/mfa/recovery-codes/regenerate`
+`POST /v1/mfa/recovery/regenerate`
```json
{
@@ -336,7 +331,7 @@ valid := mfa.VerifyRecoveryCode("a3b8c9d4", recoveryCodeRecord)
Users can disable MFA by deleting their enrollment:
-`DELETE /v1/auth/mfa/enrollment`
+`DELETE /v1/mfa/enrollment`
This removes the enrollment record and all associated recovery codes. The user will no longer be prompted for MFA on sign-in.
@@ -378,7 +373,7 @@ Built-in implementations are provided in `plugins/mfa/store_postgres.go`, `plugi
## API routes
-All MFA routes are registered under `BasePath` (default `/v1/auth`):
+All MFA routes are registered under `BasePath` (default `/v1`):
| Method | Path | Description |
|--------|------|-------------|
@@ -386,5 +381,4 @@ All MFA routes are registered under `BasePath` (default `/v1/auth`):
| `POST` | `/mfa/verify` | Verify enrollment with a code |
| `POST` | `/mfa/challenge` | Complete an MFA challenge during sign-in |
| `DELETE` | `/mfa/enrollment` | Remove MFA enrollment and recovery codes |
-| `POST` | `/mfa/recovery-codes/regenerate` | Generate new recovery codes (invalidates old ones) |
-| `GET` | `/mfa/enrollments` | List active enrollments for the current user |
+| `POST` | `/mfa/recovery/regenerate` | Generate new recovery codes (invalidates old ones) |
diff --git a/docs/content/docs/authentication/oauth2-provider.mdx b/docs/content/docs/authentication/oauth2-provider.mdx
index f4eec774..04aa6629 100644
--- a/docs/content/docs/authentication/oauth2-provider.mdx
+++ b/docs/content/docs/authentication/oauth2-provider.mdx
@@ -3,7 +3,7 @@ title: OAuth2 Provider
description: Run Authsome as an OAuth2 authorization server with Authorization Code, PKCE, and Client Credentials flows.
---
-Authsome can act as a full OAuth2 authorization server, allowing third-party applications to authenticate users and obtain access tokens through standard OAuth2 flows. This is distinct from [Social Login](/docs/authentication/social-login) (where Authsome is an OAuth2 *client*) — here, Authsome issues its own tokens on behalf of your users.
+Authsome can act as a full OAuth2 authorization server, allowing third-party applications to authenticate users and obtain access tokens through standard OAuth2 flows. This is distinct from [Social Login](/docs/authentication/social-login) (where Authsome is an OAuth2 *client*): here, Authsome issues its own tokens on behalf of your users.
## Setup
@@ -13,49 +13,41 @@ import (
"github.com/xraph/authsome/plugins/oauth2provider"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPlugin(password.New()),
- authsome.WithPlugin(oauth2provider.New()),
- authsome.WithConfig(authsome.Config{
- AppID: "myapp",
- OAuth2: authsome.OAuth2Config{
- Enabled: true,
- Issuer: "https://auth.myapp.com",
- AuthorizationEndpoint: "/oauth2/authorize",
- TokenEndpoint: "/oauth2/token",
- IntrospectionEndpoint: "/oauth2/introspect",
- RevocationEndpoint: "/oauth2/revoke",
- JWKSEndpoint: "/.well-known/jwks.json",
- AccessTokenTTL: "1h",
- RefreshTokenTTL: "30d",
- AuthorizationCodeTTL: "10m",
- RequirePKCE: true,
- AllowedScopes: []string{"openid", "profile", "email", "offline_access"},
- },
- }),
+ authsome.WithPlugin(oauth2provider.New(oauth2provider.Config{
+ Issuer: "https://auth.myapp.com",
+ AuthCodeTTL: 10 * time.Minute, // the default
+ AccessTokenTTL: time.Hour, // the default
+ })),
)
```
+The plugin's `Config` also covers the device flow (`DeviceCodeTTL`, `DeviceCodeInterval`, `VerificationURI`), dynamic client registration (`DynamicRegistration`, `RegistrationAppID`, `DynamicRegistrationScopes`, `RegistrationRateLimit`), RFC 8707 `ProtectedResources` and a `ConsentGate`. Its endpoints live under `/v1/oauth` (`/authorize`, `/token`, `/revoke`, `/userinfo`, `/consent`, `/register`, `/device/authorize`, `/device/complete`) plus `/.well-known/openid-configuration`.
+
## Client registration
OAuth2 clients must be registered before they can request tokens. Clients represent third-party applications or services that consume your authentication API.
### Create a client
-`POST /v1/auth/oauth2/clients`
+`POST /v1/admin/oauth/clients`
```json
{
"name": "My Mobile App",
- "type": "public",
+ "public": true,
"redirect_uris": ["myapp://callback", "https://myapp.com/callback"],
"scopes": ["openid", "profile", "email"],
"grant_types": ["authorization_code"],
- "app_id": "myapp"
+ "app_id": "aapp_..."
}
```
+The body also takes `resources` (RFC 8707), `dpop_mode` and `first_party`, which skips the consent page.
+
**Response:**
```json
@@ -63,7 +55,7 @@ OAuth2 clients must be registered before they can request tokens. Clients repres
"client_id": "aoc2_01jb...",
"client_secret": "sec_a3f8c9d4e5f2g7h1...",
"name": "My Mobile App",
- "type": "public",
+ "public": true,
"redirect_uris": ["myapp://callback", "https://myapp.com/callback"],
"scopes": ["openid", "profile", "email"],
"grant_types": ["authorization_code"],
@@ -75,25 +67,14 @@ OAuth2 clients must be registered before they can request tokens. Clients repres
### Client types
-| Type | Description | Use case |
-|------|-------------|----------|
-| `confidential` | Can securely store a client secret | Server-side applications, backend services |
-| `public` | Cannot securely store a client secret | Mobile apps, single-page applications, CLI tools |
+| `public` | Description | Use case |
+|----------|-------------|----------|
+| `false` (confidential) | Can securely store a client secret | Server-side applications, backend services |
+| `true` (public) | Can't store a secret, so it uses PKCE | Mobile apps, single-page applications, CLI tools |
### In Go
-```go
-client := &oauth2provider.Client{
- Name: "My Mobile App",
- Type: "public",
- RedirectURIs: []string{"myapp://callback"},
- Scopes: []string{"openid", "profile", "email"},
- GrantTypes: []string{"authorization_code"},
-}
-
-err := engine.CreateOAuth2Client(ctx, appID, client)
-// client.ID and client.Secret are now populated
-```
+The plugin has no client-creation method; register clients through the admin route above, or through `POST /v1/oauth/register` when dynamic registration is on. The model is `oauth2provider.OAuth2Client`.
## Authorization Code flow with PKCE
@@ -117,7 +98,7 @@ codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
**Authorization request**: Redirect the user to the authorization endpoint.
```
-GET /v1/auth/oauth2/authorize?
+GET /v1/oauth/authorize?
response_type=code&
client_id=aoc2_01jb...&
redirect_uri=https://myapp.com/callback&
@@ -137,7 +118,7 @@ Location: https://myapp.com/callback?code=auth_01jb...&state=random_state_value
**Token exchange**: The client exchanges the authorization code for tokens.
-`POST /v1/auth/oauth2/token`
+`POST /v1/oauth/token`
```
Content-Type: application/x-www-form-urlencoded
@@ -168,7 +149,7 @@ code_verifier=the_original_code_verifier_string
The Client Credentials grant is used for machine-to-machine authentication where no user context is needed. Only confidential clients can use this grant type.
-`POST /v1/auth/oauth2/token`
+`POST /v1/oauth/token`
```
Content-Type: application/x-www-form-urlencoded
@@ -190,13 +171,13 @@ scope=api:read api:write
}
```
-No refresh token is issued for client credentials grants — the client simply requests a new token when the current one expires.
+No refresh token is issued for client credentials grants: the client simply requests a new token when the current one expires.
## Token introspection
Resource servers can verify tokens by calling the introspection endpoint. This is useful for opaque tokens or when the resource server does not have access to the signing keys.
-`POST /v1/auth/oauth2/introspect`
+`POST /v1/introspect`
```
Content-Type: application/x-www-form-urlencoded
@@ -232,7 +213,7 @@ token=eyJhbGciOiJSUzI1NiIs...
Clients can revoke tokens when they are no longer needed (e.g., on user logout):
-`POST /v1/auth/oauth2/revoke`
+`POST /v1/oauth/revoke`
```
Content-Type: application/x-www-form-urlencoded
@@ -259,23 +240,14 @@ The endpoint always returns `200 OK` regardless of whether the token was found,
### Custom scopes
-Register custom scopes in the configuration:
-
-```go
-OAuth2: authsome.OAuth2Config{
- AllowedScopes: []string{
- "openid", "profile", "email",
- "api:read", "api:write", "admin",
- },
-},
-```
+A client may request the scopes listed in its own `scopes`. There's no global allowlist in the configuration; dynamically registered clients are limited to `DynamicRegistrationScopes`.
### Consent screen
When a third-party client requests authorization, the user is shown a consent screen listing the requested scopes. The consent decision is remembered per user-client pair. To force re-consent:
```
-GET /v1/auth/oauth2/authorize?...&prompt=consent
+GET /v1/oauth/authorize?...&prompt=consent
```
## Resource indicators (RFC 8707)
@@ -410,10 +382,10 @@ Authsome publishes an OpenID Connect discovery document:
```json
{
"issuer": "https://auth.myapp.com",
- "authorization_endpoint": "https://auth.myapp.com/v1/auth/oauth2/authorize",
- "token_endpoint": "https://auth.myapp.com/v1/auth/oauth2/token",
- "introspection_endpoint": "https://auth.myapp.com/v1/auth/oauth2/introspect",
- "revocation_endpoint": "https://auth.myapp.com/v1/auth/oauth2/revoke",
+ "authorization_endpoint": "https://auth.myapp.com/v1/oauth/authorize",
+ "token_endpoint": "https://auth.myapp.com/v1/oauth/token",
+ "introspection_endpoint": "https://auth.myapp.com/v1/introspect",
+ "revocation_endpoint": "https://auth.myapp.com/v1/oauth/revoke",
"jwks_uri": "https://auth.myapp.com/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
@@ -441,14 +413,14 @@ Authsome publishes an OpenID Connect discovery document:
| Method | Path | Description |
|--------|------|-------------|
-| `GET` | `/oauth2/authorize` | Authorization endpoint (redirects user) |
-| `POST` | `/oauth2/token` | Token endpoint (code exchange, client credentials, refresh) |
-| `POST` | `/oauth2/introspect` | Token introspection |
-| `POST` | `/oauth2/revoke` | Token revocation |
-| `POST` | `/oauth2/clients` | Register a new OAuth2 client |
-| `GET` | `/oauth2/clients` | List registered clients |
-| `GET` | `/oauth2/clients/:clientId` | Get client details |
-| `PATCH` | `/oauth2/clients/:clientId` | Update a client |
-| `DELETE` | `/oauth2/clients/:clientId` | Delete a client |
+| `GET` | `/oauth/authorize` | Authorization endpoint (redirects user) |
+| `POST` | `/oauth/token` | Token endpoint (code exchange, client credentials, refresh) |
+| `POST` | `/introspect` | Token introspection |
+| `POST` | `/oauth/revoke` | Token revocation |
+| `POST` | `/admin/oauth/clients` | Register a new OAuth2 client |
+| `GET` | `/admin/oauth/clients` | List registered clients |
+| `GET` | `/admin/oauth/clients/:clientId` | Get client details |
+| `PATCH` | `/admin/oauth/clients/:clientId` | Update a client |
+| `DELETE` | `/admin/oauth/clients/:clientId` | Delete a client |
| `GET` | `/.well-known/jwks.json` | JWKS endpoint |
| `GET` | `/.well-known/openid-configuration` | OIDC discovery document |
diff --git a/docs/content/docs/authentication/overview.mdx b/docs/content/docs/authentication/overview.mdx
index 9cc1162b..10649c66 100644
--- a/docs/content/docs/authentication/overview.mdx
+++ b/docs/content/docs/authentication/overview.mdx
@@ -3,7 +3,7 @@ title: Authentication Overview
description: How Authsome's strategy-based auth system works, and how plugins contribute authentication methods.
---
-Authsome uses a **strategy-based authentication system**. Each authentication method — password, magic link, social login, passkeys, SSO — is implemented as an independent plugin that registers one or more strategies with the engine's strategy registry.
+Authsome uses a **strategy-based authentication system**. Each authentication method (password, magic link, social login, passkeys, SSO) is implemented as an independent plugin that registers one or more strategies with the engine's strategy registry.
## Strategy-based authentication
@@ -66,7 +66,7 @@ for _, s := range e.strategies.Sorted() {
if err != nil {
return nil, nil, err
}
- // Strategy accepted and authenticated -- proceed to session creation.
+ // Strategy accepted and authenticated; proceed to session creation.
break
}
}
@@ -80,13 +80,13 @@ When MFA is enrolled, authentication is a two-step process:
-**Step 1 — Primary authentication**: The password (or other) strategy authenticates the user and verifies credentials.
+**Step 1, primary authentication**: The password (or other) strategy checks the credentials.
-**Step 2 — MFA challenge**: If the user has MFA enrolled, the engine returns an `ErrMFARequired` sentinel error along with an `mfa_challenge_id`. No session is created yet.
+**Step 2, MFA required**: If MFA applies, the engine returns an `*authsome.MFARequiredError` (it unwraps to `account.ErrMFARequired`) carrying an `mfa_ticket` and the available methods. No session is created yet. Over HTTP this is a 403 with `"type": "mfa_required"`.
-**Step 3 — MFA verification**: The client submits the TOTP code or SMS OTP along with the `mfa_challenge_id`. On success, a session is created.
+**Step 3, MFA verification**: The client posts the ticket and a TOTP or recovery code to `POST /v1/mfa/challenge` (`{"mfa_ticket": "...", "code": "123456"}`). On success, a session is created.
@@ -98,12 +98,10 @@ u, sess, err := engine.SignIn(ctx, &account.SignInRequest{
AppID: appID,
})
-if errors.Is(err, mfa.ErrMFARequired) {
- // Extract the challenge ID from the error.
- challengeID := mfa.ChallengeIDFromError(err)
-
- // Step 3 (after user submits TOTP code):
- sess, err = mfaPlugin.VerifyChallenge(ctx, challengeID, totpCode)
+var mfaErr *authsome.MFARequiredError
+if errors.As(err, &mfaErr) {
+ // Send mfaErr.Ticket and mfaErr.AvailableMethods to the client, which
+ // completes the second factor at POST /v1/mfa/challenge.
}
```
diff --git a/docs/content/docs/authentication/passkeys.mdx b/docs/content/docs/authentication/passkeys.mdx
index 5677a77e..a4c3e38e 100644
--- a/docs/content/docs/authentication/passkeys.mdx
+++ b/docs/content/docs/authentication/passkeys.mdx
@@ -13,16 +13,17 @@ import (
"github.com/xraph/authsome/plugins/passkey"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPlugin(passkey.New(passkey.Config{
- // Relying Party ID — must match the effective domain of your origin.
+ // Relying Party ID: must match the effective domain of your origin.
RPID: "myapp.com",
- // Relying Party display name — shown in the browser passkey UI.
+ // Relying Party display name: shown in the browser passkey UI.
RPDisplayName: "My Application",
- // Allowed origins — must include your frontend's exact origin
+ // Allowed origins: must include your frontend's exact origin
// (scheme + host + port). For production, set explicitly.
RPOrigins: []string{
"https://myapp.com",
@@ -34,13 +35,13 @@ eng, err := authsome.New(
### Local development
-For `RPID: "localhost"` (or `"127.0.0.1"`) the plugin auto-trusts the request's `Origin` header on every ceremony, **as long as its host matches the configured RPID**. That means any localhost dev port — `http://localhost:3000`, `http://localhost:5173`, etc. — works without listing it in `RPOrigins`. The host check still rejects foreign origins, so this shortcut only loosens the *port* requirement, never the *host*.
+For `RPID: "localhost"` (or `"127.0.0.1"`) the plugin auto-trusts the request's `Origin` header on every ceremony, **as long as its host matches the configured RPID**. That means any localhost dev port, `http://localhost:3000`, `http://localhost:5173`, etc., works without listing it in `RPOrigins`. The host check still rejects foreign origins, so this shortcut only loosens the *port* requirement, never the *host*.
For non-localhost RPIDs, behavior is strict: the browser's origin must match an entry in `RPOrigins` (scheme, host, and port included) exactly.
### Dynamic origins setting
-`passkey.rp_origins` (`[]string`) is a dynamic setting that adds extra allowed origins on top of `Config.RPOrigins`. Use it when origins differ between environments and you'd rather not rebuild the binary — e.g. set it from the dashboard or via your settings store for staging vs. prod.
+`passkey.rp_origins` (`[]string`) is a dynamic setting that adds extra allowed origins on top of `Config.RPOrigins`. Use it when origins differ between environments and you'd rather not rebuild the binary: e.g. set it from the dashboard or via your settings store for staging vs. prod.
The `passkey` plugin uses the engine's `ceremony.Store` to hold in-progress registration and authentication state between the challenge and response calls. The default in-memory store is used when none is configured by the engine.
@@ -53,7 +54,7 @@ WebAuthn uses public-key cryptography:
1. During **registration**, the authenticator generates a key pair. The public key is stored on the server. The private key never leaves the device.
2. During **authentication**, the server sends a challenge. The authenticator signs it with the private key. The server verifies the signature with the stored public key.
-Because the private key is device-bound and the challenge is domain-scoped, passkeys are immune to phishing — a fake site cannot obtain a valid authentication signature.
+Because the private key is device-bound and the challenge is domain-scoped, passkeys are immune to phishing: a fake site cannot obtain a valid authentication signature.
## Registration ceremony
@@ -63,7 +64,7 @@ The registration flow runs when an existing user wants to add a passkey to their
**Begin registration**: The server generates a challenge and public key credential creation options.
-`POST /v1/auth/passkey/register/begin`
+`POST /v1/passkeys/register/begin`
Request headers must include `Authorization: Bearer {session_token}` (user must be authenticated). The session must also be recent: registration is refused with `403` unless the user signed in within the last five minutes (`Config.StepUpWindow`), because a passkey is itself a sign-in credential. Once a passkey is registered, every other session of the user is signed out.
@@ -87,7 +88,7 @@ Response includes the WebAuthn `PublicKeyCredentialCreationOptions` JSON:
**Finish registration**: Submit the credential to the server.
-`POST /v1/auth/passkey/register/finish`
+`POST /v1/passkeys/register/finish`
```json
{
@@ -115,7 +116,7 @@ The authentication flow allows users to sign in without a password.
**Begin authentication**: The server generates a challenge.
-`POST /v1/auth/passkey/authenticate/begin`
+`POST /v1/passkeys/login/begin`
```json
{"email": "alice@example.com", "app_id": "myapp"}
@@ -141,7 +142,7 @@ Response includes the WebAuthn `PublicKeyCredentialRequestOptions`:
**Finish authentication**: Submit the assertion.
-`POST /v1/auth/passkey/authenticate/finish`
+`POST /v1/passkeys/login/finish`
```json
{
@@ -205,10 +206,11 @@ engine.DeletePasskey(ctx, passkeyID)
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/passkey/register/begin` | Begin passkey registration ceremony |
-| `POST` | `/passkey/register/finish` | Finish passkey registration |
-| `POST` | `/passkey/authenticate/begin` | Begin passkey authentication ceremony |
-| `POST` | `/passkey/authenticate/finish` | Finish authentication and return session |
-| `GET` | `/passkey` | List all passkeys for the current user |
-| `PATCH` | `/passkey/{id}` | Rename a passkey |
-| `DELETE` | `/passkey/{id}` | Delete a passkey |
+| `POST` | `/v1/passkeys/register/begin` | Begin passkey registration ceremony |
+| `POST` | `/v1/passkeys/register/finish` | Finish passkey registration |
+| `POST` | `/v1/passkeys/login/begin` | Begin passkey authentication ceremony |
+| `POST` | `/v1/passkeys/login/finish` | Finish authentication and return session |
+| `GET` | `/v1/passkeys` | List the current user's passkeys |
+| `DELETE` | `/v1/passkeys/{id}` | Delete a passkey |
+
+There's no route to rename a passkey.
diff --git a/docs/content/docs/authentication/password.mdx b/docs/content/docs/authentication/password.mdx
index 615aee08..ce6b4be9 100644
--- a/docs/content/docs/authentication/password.mdx
+++ b/docs/content/docs/authentication/password.mdx
@@ -13,7 +13,8 @@ import (
"github.com/xraph/authsome/plugins/password"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPlugin(password.New()),
authsome.WithConfig(authsome.Config{
@@ -43,7 +44,7 @@ password.New(password.Config{
## Sign-up flow
-`POST /v1/auth/signup`
+`POST /v1/signup`
The signup endpoint creates a user account and returns a session in a single call.
@@ -54,7 +55,8 @@ The signup endpoint creates a user account and returns a session in a single cal
"email": "alice@example.com",
"password": "Secure!Pass99",
"username": "alice",
- "name": "Alice Liddell",
+ "first_name": "Alice",
+ "last_name": "Liddell",
"app_id": "myapp",
"metadata": {
"company": "Acme Corp",
@@ -71,7 +73,8 @@ The signup endpoint creates a user account and returns a session in a single cal
"id": "ausr_01j9...",
"email": "alice@example.com",
"username": "alice",
- "name": "Alice Liddell",
+ "first_name": "Alice",
+ "last_name": "Liddell",
"email_verified": false,
"app_id": "aapp_01j9...",
"metadata": {"company": "Acme Corp", "plan": "pro"},
@@ -102,7 +105,7 @@ The engine performs these checks before creating the user:
## Sign-in flow
-`POST /v1/auth/signin`
+`POST /v1/signin`
```json
{
@@ -146,7 +149,7 @@ Password: authsome.PasswordConfig{
}
```
-Increasing the cost factor makes hashing slower and more resistant to brute-force attacks. Each increment doubles the computation time. A cost of 12 takes approximately 250ms on modern hardware — acceptable for sign-in but not for high-throughput bulk operations.
+Increasing the cost factor makes hashing slower and more resistant to brute-force attacks. Each increment doubles the computation time. A cost of 12 takes approximately 250ms on modern hardware: acceptable for sign-in but not for high-throughput bulk operations.
### argon2id
@@ -201,7 +204,7 @@ Password: authsome.PasswordConfig{
### Breach checking
-When `CheckBreached: true`, the engine queries the HaveIBeenPwned [k-anonymity API](https://haveibeenpwned.com/API/v3#PwnedPasswords) before storing a password. Only the first 5 characters of the SHA-1 hash are sent — the full password is never transmitted.
+When `CheckBreached: true`, the engine queries the HaveIBeenPwned [k-anonymity API](https://haveibeenpwned.com/API/v3#PwnedPasswords) before storing a password. Only the first 5 characters of the SHA-1 hash are sent: the full password is never transmitted.
The check is fail-open: if the API is unreachable or returns an error, the password is accepted. Use this for consumer-facing applications where security is important but user experience must not be blocked by external API failures.
@@ -212,7 +215,8 @@ When `HistoryCount > 0`, Authsome stores previous password hashes in the `Passwo
```go
import "github.com/xraph/authsome/account"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPasswordHistory(store), // store must implement account.PasswordHistoryStore
authsome.WithConfig(authsome.Config{
@@ -233,13 +237,13 @@ The password reset flow is a two-step process:
**Request reset**: The user submits their email address.
-`POST /v1/auth/forgot-password`
+`POST /v1/forgot-password`
```json
{"email": "alice@example.com", "app_id": "myapp"}
```
-The engine creates a password reset record with a 1-hour expiry token. The response is always `200 OK` regardless of whether the email exists — this prevents email enumeration.
+The engine creates a password reset record with a 1-hour expiry token. The response is always `200 OK` regardless of whether the email exists: this prevents email enumeration.
The reset record is returned to your code (not sent directly). You are responsible for delivering the token via email using your Mailer bridge or custom logic:
@@ -254,7 +258,7 @@ if pr != nil {
**Confirm reset**: The user submits the token and new password.
-`POST /v1/auth/reset-password`
+`POST /v1/reset-password`
```json
{
@@ -271,7 +275,7 @@ The engine validates the token, enforces the password policy, checks breach stat
Authenticated users can change their password by providing their current password:
-`POST /v1/auth/change-password`
+`POST /v1/change-password`
```json
{
@@ -280,11 +284,11 @@ Authenticated users can change their password by providing their current passwor
}
```
-Requires a valid session token in the `Authorization: Bearer` header. The engine verifies the current password before applying the change. All existing sessions are **not** automatically revoked — only the user's password record is updated.
+Requires a valid session token in the `Authorization: Bearer` header. The engine verifies the current password before applying the change. All existing sessions are **not** automatically revoked: only the user's password record is updated.
## API routes
-All routes are registered under `BasePath` (default `/v1/auth`):
+All routes are registered under `BasePath` (default `/v1`):
| Method | Path | Description |
|--------|------|-------------|
@@ -293,7 +297,7 @@ All routes are registered under `BasePath` (default `/v1/auth`):
| `POST` | `/signout` | Revoke current session |
| `POST` | `/refresh` | Exchange refresh token for new access token |
| `GET` | `/me` | Return authenticated user |
-| `PUT` | `/me` | Update authenticated user profile |
+| `PATCH` | `/v1/me` | Update authenticated user profile |
| `POST` | `/forgot-password` | Request password reset token |
| `POST` | `/reset-password` | Confirm reset with token + new password |
| `POST` | `/change-password` | Change password (requires current password) |
diff --git a/docs/content/docs/authentication/phone-auth.mdx b/docs/content/docs/authentication/phone-auth.mdx
index ffdc321b..e12a4c70 100644
--- a/docs/content/docs/authentication/phone-auth.mdx
+++ b/docs/content/docs/authentication/phone-auth.mdx
@@ -21,18 +21,14 @@ smsSender := smsadapter.NewTwilioSender(
"+15559876543", // From number
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
- authsome.WithPlugin(phone.New()),
- authsome.WithSMSSender(smsSender),
- authsome.WithConfig(authsome.Config{
- AppID: "myapp",
- Phone: authsome.PhoneConfig{
- CodeLength: 6, // number of digits in OTP
- CodeTTL: "5m", // code validity window
- MaxAttempts: 3, // max verification attempts per code
- },
- }),
+ authsome.WithPlugin(phone.New(phone.Config{
+ CodeTTL: 5 * time.Minute, // the default
+ // AutoCreate defaults to true: an unknown number gets a new account.
+ })),
+ authsome.WithSMSSender(smsSender), // or phone.Config{SMSSender: ...}
)
```
@@ -46,7 +42,7 @@ Phone-based sign-in is a two-step process: request a code, then verify it.
**Request code**: The user submits their phone number.
-`POST /v1/auth/phone/send-code`
+`POST /v1/phone/start`
```json
{
@@ -69,7 +65,7 @@ The engine generates a random 6-digit numeric code and sends it via the configur
**Verify code**: The user submits the code they received.
-`POST /v1/auth/phone/verify`
+`POST /v1/phone/verify`
```json
{
@@ -124,7 +120,7 @@ valid := mfa.ValidateSMSCode("847293", challenge)
When using phone as an additional identity attribute (not the primary sign-in method), users can verify their phone number through the same OTP flow:
-`POST /v1/auth/phone/verify-number`
+`POST /v1/phone/verify`
```json
{
@@ -192,7 +188,8 @@ func (s *MyCustomSender) SendSMS(ctx context.Context, msg *bridge.SMSMessage) er
Then pass it to the engine:
```go
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithSMSSender(&MyCustomSender{
apiKey: os.Getenv("SMS_API_KEY"),
}),
@@ -212,13 +209,13 @@ eng, err := authsome.New(
## API routes
-All phone auth routes are registered under `BasePath` (default `/v1/auth`):
+All phone auth routes are registered under `BasePath` (default `/v1`):
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/phone/send-code` | Request an SMS OTP for sign-in |
+| `POST` | `/phone/start` | Request an SMS OTP for sign-in |
| `POST` | `/phone/verify` | Verify OTP and sign in (or sign up) |
-| `POST` | `/phone/verify-number` | Verify phone number for an authenticated user |
+| `POST` | `/phone/verify` | Verify phone number for an authenticated user |
## Security considerations
diff --git a/docs/content/docs/authentication/social-login.mdx b/docs/content/docs/authentication/social-login.mdx
index 3afc16c4..84b9d458 100644
--- a/docs/content/docs/authentication/social-login.mdx
+++ b/docs/content/docs/authentication/social-login.mdx
@@ -17,7 +17,8 @@ import (
"github.com/xraph/authsome/plugins/social"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPlugin(social.New(social.Config{
Providers: []social.Provider{
@@ -25,28 +26,28 @@ eng, err := authsome.New(
Name: "google",
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/google/callback",
+ RedirectURL: "https://myapp.com/v1/social/google/callback",
Scopes: []string{"openid", "email", "profile"},
},
{
Name: "github",
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/github/callback",
+ RedirectURL: "https://myapp.com/v1/social/github/callback",
Scopes: []string{"read:user", "user:email"},
},
{
Name: "microsoft",
ClientID: os.Getenv("MICROSOFT_CLIENT_ID"),
ClientSecret: os.Getenv("MICROSOFT_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/microsoft/callback",
+ RedirectURL: "https://myapp.com/v1/social/microsoft/callback",
Tenant: "common", // or specific tenant ID for AAD
},
{
Name: "apple",
ClientID: os.Getenv("APPLE_CLIENT_ID"),
ClientSecret: os.Getenv("APPLE_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/apple/callback",
+ RedirectURL: "https://myapp.com/v1/social/apple/callback",
TeamID: os.Getenv("APPLE_TEAM_ID"),
KeyID: os.Getenv("APPLE_KEY_ID"),
PrivateKey: os.Getenv("APPLE_PRIVATE_KEY"),
@@ -94,16 +95,16 @@ type Provider struct {
-**Initiate the flow**: Your frontend redirects the user to the Authsome authorization URL.
+**Initiate the flow**: Your frontend asks for the provider's authorization URL and sends the user there.
-`GET /v1/auth/social/{provider}/authorize?app_id=myapp&state=optional-state`
+`POST /v1/social/{provider}`
-The engine generates a PKCE challenge, stores the state in the ceremony store, and returns a redirect to the provider's authorization page.
+The plugin generates the state (and a PKCE challenge where the provider supports it), stores it in the ceremony store and returns `{"auth_url": "..."}`.
**Provider redirects back**: After the user consents, the provider redirects to your callback URL with a `code` and `state` parameter.
-`GET https://myapp.com/v1/auth/social/{provider}/callback?code=...&state=...`
+`GET https://myapp.com/v1/social/{provider}/callback?code=...&state=...`
The Authsome callback handler:
1. Validates the `state` parameter against the ceremony store.
@@ -130,60 +131,52 @@ By default, new users are created automatically on their first social sign-in. T
|----------------|--------|--------|-----------|-------|
| `email` | `email` | primary email | `mail` | `email` |
| `name` | `name` | `name` | `displayName` | `name.firstName + lastName` |
-| `image` | `picture` | `avatar_url` | — | — |
-| `email_verified` | `email_verified` | always `true` | — | — |
+| `image` | `picture` | `avatar_url` | none | none |
+| `email_verified` | `email_verified` | always `true` | none | none |
## Account linking
-If a user already has a password account with the same email and then signs in with Google, Authsome automatically links the Google account to the existing user. The user can then sign in with either method.
+The callback resolves the user in three steps:
-To prevent automatic account linking (e.g., to require explicit confirmation), you can disable it:
+1. A connection for the same provider account (provider plus provider user ID) wins, whatever the email says now.
+2. Otherwise an existing account is linked when one of the provider's verified emails matches a verified address on it. If more than one account matches, the sign-in is refused with 409 instead of merging them. An unverified address on either side never links.
+3. Otherwise a new user is created from the provider's profile.
-```go
-social.New(social.Config{
- AutoLink: false, // default: true
- Providers: []social.Provider{...},
-})
-```
-
-When `AutoLink: false`, signing in with a provider whose email matches an existing account returns an error. Your UI can then prompt the user to sign in with their existing method first, then explicitly link the account.
+There's no option to turn linking off.
-## The OAuthAccount entity
+## The OAuthConnection entity
**ID prefix:** `aoau_`
-Each linked social account is stored as an `OAuthAccount`:
+Each linked social account is stored as an `OAuthConnection` in `plugins/social`:
```go
-type OAuthAccount struct {
- ID id.OAuthAccountID `json:"id"`
- UserID id.UserID `json:"user_id"`
- AppID id.AppID `json:"app_id"`
- Provider string `json:"provider"` // "google", "github"
- ProviderUserID string `json:"provider_user_id"` // provider's user ID
- Email string `json:"email"`
- AccessToken string `json:"-"` // never serialized
- RefreshToken string `json:"-"`
- ExpiresAt *time.Time `json:"expires_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+type OAuthConnection struct {
+ ID id.OAuthConnectionID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ UserID id.UserID `json:"user_id"`
+ Provider string `json:"provider"` // "google", "github"
+ ProviderUserID string `json:"provider_user_id"` // the provider's user ID
+ Email string `json:"email"`
+ AccessToken string `json:"-"`
+ RefreshToken string `json:"-"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
-A user can have multiple `OAuthAccount` records (one per provider). Retrieve them:
-
-```go
-accounts, err := engine.ListUserOAuthAccounts(ctx, userID)
-```
+A user can have several, one per provider. `GET /v1/me/auth-methods` lists a user's sign-in methods, linked providers included, and `eng.ListAuthMethods(ctx, userID)` does the same from Go.
## Unlinking a provider
Users can unlink a social account as long as they have another sign-in method (password or another provider):
-`DELETE /v1/auth/social/{provider}/unlink`
+`DELETE /v1/me/auth-methods/{provider}`
```go
-err := engine.UnlinkSocialAccount(ctx, userID, "github")
+err := eng.UnlinkAuthMethod(ctx, userID, "github")
```
The engine prevents unlinking when it would leave the user with no way to sign in.
@@ -192,7 +185,7 @@ The engine prevents unlinking when it would leave the user with no way to sign i
| Method | Path | Description |
|--------|------|-------------|
-| `GET` | `/social/{provider}/authorize` | Redirect to provider authorization page |
-| `GET` | `/social/{provider}/callback` | Handle provider callback and return session |
-| `DELETE` | `/social/{provider}/unlink` | Unlink a social account |
-| `GET` | `/social/accounts` | List all linked social accounts for the current user |
+| `POST` | `/v1/social/{provider}` | Start the flow; returns the provider's authorization URL |
+| `GET` | `/v1/social/{provider}/callback` | Handle the provider callback and return a session |
+| `GET` | `/v1/me/auth-methods` | List the current user's sign-in methods |
+| `DELETE` | `/v1/me/auth-methods/{provider}` | Unlink a provider |
diff --git a/docs/content/docs/authentication/sso.mdx b/docs/content/docs/authentication/sso.mdx
index 8ae3b307..bd75db54 100644
--- a/docs/content/docs/authentication/sso.mdx
+++ b/docs/content/docs/authentication/sso.mdx
@@ -3,7 +3,7 @@ title: Enterprise SSO
description: OIDC and SAML-based enterprise single sign-on with JIT user provisioning.
---
-The `sso` plugin enables enterprise customers to sign in using their company's identity provider (IdP) through OpenID Connect (OIDC) or SAML 2.0. Each enterprise connection is a separate `SSOConnection` scoped to an organization within your app.
+The `sso` plugin lets enterprise customers sign in through their company's identity provider (IdP) with OpenID Connect (OIDC) or SAML 2.0. Providers come from two places: the static list you pass in `sso.Config.Providers`, and connections created at runtime and stored per app, optionally scoped to one organization.
## Setup
@@ -13,99 +13,54 @@ import (
"github.com/xraph/authsome/plugins/sso"
)
-eng, err := authsome.New(
+ssoPlugin := sso.New(sso.Config{
+ // Static providers. Optional when you create connections at runtime.
+ Providers: []sso.Provider{
+ sso.NewOIDCProvider(sso.OIDCConfig{
+ Name: "okta",
+ Issuer: "https://acme.okta.com",
+ ClientID: "0oa1abcdef123456",
+ ClientSecret: os.Getenv("OKTA_CLIENT_SECRET"),
+ RedirectURL: "https://myapp.com/v1/sso/okta/callback",
+ Scopes: []string{"openid", "email", "profile"},
+ }),
+ },
+ // Public base URL, used for callback and ACS URLs.
+ PublicBaseURL: "https://myapp.com",
+ // Frontend origins a login may return to.
+ AllowedReturnOrigins: []string{"https://app.myapp.com"},
+})
+
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
- authsome.WithPlugin(sso.New(sso.Config{
- // Default OIDC callback URL pattern.
- // {connection_id} is replaced with the SSO connection ID.
- OIDCCallbackURL: "https://myapp.com/v1/auth/sso/{connection_id}/callback",
-
- // Default SAML ACS URL pattern.
- SAMLCallbackURL: "https://myapp.com/v1/auth/sso/{connection_id}/acs",
-
- // JIT provisioning: create users automatically on first SSO sign-in.
- JITProvision: true,
-
- // Attribute mapping from IdP claims to Authsome user fields.
- AttributeMapping: sso.AttributeMapping{
- Email: "email",
- Name: "name",
- FirstName: "given_name",
- LastName: "family_name",
- },
- })),
+ authsome.WithPlugin(ssoPlugin),
)
```
-## OIDC provider configuration
-
-Create an SSO connection for an OIDC provider (Okta, Azure AD, Google Workspace, etc.):
-
-```go
-conn := &sso.Connection{
- ID: id.NewSSOConnectionID(),
- AppID: appID,
- OrganizationID: orgID, // scoped to a specific org (optional)
- Type: sso.TypeOIDC,
- Name: "Acme Corp - Okta",
- Slug: "acme-okta",
- Active: true,
- OIDC: &sso.OIDCConfig{
- Issuer: "https://acme.okta.com",
- ClientID: "0oa1abcdef123456",
- ClientSecret: "OIDC_CLIENT_SECRET",
- Scopes: []string{"openid", "email", "profile", "groups"},
- // Discovery URL is auto-derived from Issuer: {Issuer}/.well-known/openid-configuration
- },
-}
+`sso.Config` also has `SessionTokenTTL` and `SessionRefreshTTL`. [The SSO plugin page](/docs/plugins/sso) covers `NewOIDCProvider` and `NewSAMLProvider` in detail.
-if err := engine.CreateSSOConnection(ctx, conn); err != nil {
- log.Fatal(err)
-}
-```
+## Creating connections at runtime
-For providers that don't support OIDC discovery, specify endpoints explicitly:
+A connection is one customer's IdP, matched by email domain:
```go
-OIDC: &sso.OIDCConfig{
- Issuer: "https://login.example.com",
- ClientID: "my-client-id",
- ClientSecret: "my-secret",
- AuthURL: "https://login.example.com/oauth2/authorize",
- TokenURL: "https://login.example.com/oauth2/token",
- UserInfoURL: "https://login.example.com/oauth2/userinfo",
- JWKSURL: "https://login.example.com/.well-known/jwks.json",
-}
+conn, err := ssoPlugin.CreateConnection(ctx, sso.CreateConnectionInput{
+ AppID: appID,
+ OrgID: orgID, // zero value means app-wide
+ Provider: "acme-okta",
+ Protocol: "oidc", // or "saml"
+ Domain: "acme.com",
+ Issuer: "https://acme.okta.com",
+ ClientID: "0oa1abcdef123456",
+ ClientSecret: oktaSecret,
+ Enforced: true, // block password sign-in for acme.com (org owners and admins excepted)
+})
```
-## SAML provider configuration
+For SAML, set `Protocol: "saml"` and `MetadataURL` (or `IDPMetadataXML`, or `IDPSSOURL` with `IDPCertificate`), plus `EntityID`, `ACSURL`, `SignRequests` and `AttributeMappings` as needed.
-```go
-conn := &sso.Connection{
- ID: id.NewSSOConnectionID(),
- AppID: appID,
- Type: sso.TypeSAML,
- Name: "Acme Corp - SAML IdP",
- Slug: "acme-saml",
- SAML: &sso.SAMLConfig{
- // Metadata URL (preferred — auto-fetches IdP certificate and endpoints)
- MetadataURL: "https://idp.acme.com/saml/metadata",
-
- // Or specify manually:
- IDPSSOSURL: "https://idp.acme.com/saml/sso",
- IDPEntityID: "https://idp.acme.com",
- IDPCertificate: "-----BEGIN CERTIFICATE-----\n...",
-
- // Your SP entity ID (must match what's registered in the IdP)
- SPEntityID: "https://myapp.com",
- AttributeMapping: map[string]string{
- "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
- "first_name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
- "last_name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
- },
- },
-}
-```
+The stored connections are managed through `ssoPlugin.SSOStore()`, which has `GetConnection`, `GetConnectionByDomain`, `GetConnectionByProvider`, `ListConnections`, `UpdateConnection` and `DeleteConnection`. Set `Active` to false to switch one off without deleting it. Admins can also manage them over HTTP (below).
## Enterprise SSO flow
@@ -113,25 +68,19 @@ conn := &sso.Connection{
**Identify the connection**: The user enters their email and the engine looks up the SSO connection by email domain.
-`POST /v1/auth/sso/identify`
+`POST /v1/sso/login`
```json
-{"email": "alice@acme.com", "app_id": "myapp"}
+{"email": "alice@acme.com", "return_url": "https://app.myapp.com/after-login"}
```
-Response:
+`connection_id` pins one connection when the same domain belongs to several organizations. The response carries the IdP URL to send the browser to:
```json
-{
- "connection_id": "asso_01j9...",
- "connection_name": "Acme Corp - Okta",
- "type": "oidc"
-}
+{"login_url": "https://acme.okta.com/oauth2/v1/authorize?...", "state": "..."}
```
-Alternatively, the connection can be identified by slug (for direct SSO links):
-
-`GET /v1/auth/sso/{slug}/authorize?app_id=myapp`
+If you already know the provider, `POST /v1/sso/{provider}/login` starts the same flow by name.
**Redirect to IdP**: The engine generates the authorization URL (OIDC) or a SAML AuthnRequest (SAML) and redirects the user.
@@ -139,86 +88,35 @@ Alternatively, the connection can be identified by slug (for direct SSO links):
**IdP callback**: The user authenticates at the IdP and is redirected back to the Authsome callback URL.
-For OIDC: `GET /v1/auth/sso/{connection_id}/callback?code=...&state=...`
-For SAML: `POST /v1/auth/sso/{connection_id}/acs` (with `SAMLResponse` body)
+For OIDC: `GET /v1/sso/{provider}/callback?code=...&state=...`
+For SAML: `POST /v1/sso/{provider}/acs` (with `SAMLResponse` body)
-**JIT provisioning (first sign-in)**: If `JITProvision: true` and the user doesn't have an Authsome account, one is created automatically using attributes from the IdP assertion.
+**First sign-in**: If no user is linked to the IdP subject yet, the plugin links the existing account with that email (only if its email is verified; an unverified one is refused) or creates one, as long as the email's domain is the connection's `Domain` or in its `AllowedDomains`.
**Session created**: A full user + session is returned to the client.
-## JIT provisioning
-
-Just-in-time provisioning creates Authsome user accounts automatically on first SSO sign-in. The user's attributes are mapped from the IdP token or assertion to Authsome user fields using the `AttributeMapping` configuration.
-
-```go
-// Users created via JIT provisioning:
-// - Have email_verified: true (IdP has already verified the email)
-// - Have no password (can only sign in via SSO)
-// - Are automatically added to the organization linked to the SSO connection
-```
-
-To disable JIT provisioning and require pre-created accounts:
-
-```go
-sso.New(sso.Config{
- JITProvision: false,
-})
-```
-
-When `JITProvision: false`, signing in with an unknown email returns `ErrInvalidCredentials`.
-
-## SSO connection management
-
-```go
-// Create
-engine.CreateSSOConnection(ctx, conn)
-
-// Get by ID
-conn, err := engine.GetSSOConnection(ctx, connID)
-
-// Get by slug (for URL-based routing)
-conn, err := engine.GetSSOConnectionBySlug(ctx, appID, "acme-okta")
-
-// Get by email domain (for auto-identification)
-conn, err := engine.GetSSOConnectionByDomain(ctx, appID, "acme.com")
-
-// List all connections for an app
-conns, err := engine.ListSSOConnections(ctx, appID)
-
-// Update (e.g., rotate client secret)
-engine.UpdateSSOConnection(ctx, conn)
-
-// Deactivate (disables sign-in but preserves config)
-engine.DeactivateSSOConnection(ctx, connID)
-```
-
-## Domain-based connection routing
-
-Associate email domains with SSO connections to enable automatic IdP identification:
-
-```go
-conn.DomainMappings = []string{"acme.com", "acmecorp.com"}
-engine.UpdateSSOConnection(ctx, conn)
-```
+## Provisioning on first sign-in
-When a user enters `alice@acme.com` on the sign-in page, the engine automatically identifies the Acme Corp OIDC connection and redirects to Okta — no manual selection required.
+There's no switch for just-in-time provisioning: an SSO sign-in for an unknown address in an allowed domain creates the user. Restrict who can get in through the connection's `Domain` and `AllowedDomains`. With `TrustedFederation` on a connection, a user created through it takes the IdP's subject as the local user ID; it's off by default.
## API routes
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/sso/identify` | Identify SSO connection by email |
-| `GET` | `/sso/{slug}/authorize` | Initiate SSO flow by connection slug |
-| `GET` | `/sso/{id}/callback` | OIDC callback handler |
-| `POST` | `/sso/{id}/acs` | SAML ACS (Assertion Consumer Service) handler |
-| `GET` | `/sso/{id}/metadata` | SAML SP metadata XML |
-| `POST` | `/admin/sso/connections` | Create SSO connection (admin) |
-| `GET` | `/admin/sso/connections` | List SSO connections (admin) |
-| `PUT` | `/admin/sso/connections/{id}` | Update SSO connection (admin) |
-| `DELETE` | `/admin/sso/connections/{id}` | Delete SSO connection (admin) |
+| `POST` | `/v1/sso/login` | Start a login from an email address |
+| `POST` | `/v1/sso/{provider}/login` | Start a login with a named provider |
+| `GET`, `POST` | `/v1/sso/{provider}/callback` | OIDC callback |
+| `POST` | `/v1/sso/{provider}/acs` | SAML Assertion Consumer Service |
+| `GET` | `/v1/sso/{provider}/metadata` | SAML SP metadata XML |
+| `POST` | `/v1/sso/exchange` | Exchange the post-login code for a session |
+| `POST` | `/v1/admin/sso/connections` | Create a connection (admin) |
+| `GET` | `/v1/admin/sso/connections` | List connections (admin) |
+| `GET` | `/v1/admin/sso/connections/{id}` | Get a connection (admin) |
+| `PUT` | `/v1/admin/sso/connections/{id}` | Update a connection (admin) |
+| `DELETE` | `/v1/admin/sso/connections/{id}` | Delete a connection (admin) |
The admin routes act only on the caller's own app. `app_id` in the create body and the list query is optional and must name that app; any other value is refused. A connection that belongs to another app is answered as not found.
diff --git a/docs/content/docs/bridges/chronicle.mdx b/docs/content/docs/bridges/chronicle.mdx
index 35029dde..702baded 100644
--- a/docs/content/docs/bridges/chronicle.mdx
+++ b/docs/content/docs/bridges/chronicle.mdx
@@ -3,7 +3,7 @@ title: Chronicle Bridge
description: Audit logging integration that records every auth event to a Chronicle backend.
---
-The Chronicle bridge connects Authsome to the Chronicle audit logging extension. When configured, every authentication event — sign-in, sign-up, MFA enrollment, password reset, session revocation, and more — is recorded to Chronicle's append-only audit trail with full actor, tenant, outcome, and severity metadata.
+The Chronicle bridge connects Authsome to Chronicle's audit trail. It's required: `NewEngine` returns `ErrChronicleRequired` without `WithChronicle`. Every authentication event (sign-in, sign-up, MFA enrollment, password reset, session revocation and more) is recorded with its actor, tenant, outcome and severity.
## Interface
@@ -20,10 +20,18 @@ type AuditEvent struct {
ResourceID string `json:"resource_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
Tenant string `json:"tenant,omitempty"`
+ OrgID string `json:"org_id,omitempty"`
Outcome string `json:"outcome"`
Severity string `json:"severity"`
+ Category string `json:"category,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Reason string `json:"reason,omitempty"`
+
+ // Request correlation, filled in from the request context.
+ IP string `json:"ip,omitempty"`
+ UserAgent string `json:"user_agent,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
}
```
@@ -55,10 +63,9 @@ import (
"github.com/xraph/chronicle"
)
-// Build the Chronicle engine (see Chronicle docs for full setup).
+// Build the Chronicle engine (see Chronicle's docs for stores).
chronicleEng, err := chronicle.New(
chronicle.WithStore(chronicleStore),
- chronicle.WithAppID("myapp"),
)
if err != nil {
log.Fatal(err)
@@ -68,8 +75,9 @@ if err != nil {
chronicleBridge := chronicleadapter.New(chronicleEng)
// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(pgStore),
+ authsome.WithWarden(wardenEngine),
authsome.WithPlugin(password.New()),
authsome.WithChronicle(chronicleBridge),
)
@@ -77,51 +85,40 @@ eng, err := authsome.New(
## Events recorded
-The Chronicle bridge receives events for every Authsome operation. The table below lists the most important events:
-
-| Action | Resource | Severity | Trigger |
-|--------|----------|----------|---------|
-| `auth.signup` | `user` | `info` | New user registered |
-| `auth.signin` | `session` | `info` | Successful sign-in |
-| `auth.signin.failed` | `session` | `warning` | Failed sign-in attempt |
-| `auth.signout` | `session` | `info` | User signed out |
-| `auth.password.reset` | `account` | `info` | Password reset completed |
-| `auth.password.change` | `account` | `info` | Password changed |
-| `auth.mfa.enrolled` | `mfa` | `info` | MFA method enrolled |
-| `auth.mfa.verified` | `mfa` | `info` | MFA challenge passed |
-| `auth.mfa.failed` | `mfa` | `warning` | MFA challenge failed |
-| `auth.passkey.registered` | `passkey` | `info` | Passkey registered |
-| `auth.passkey.authenticated` | `passkey` | `info` | Passkey login |
-| `auth.social.signin` | `session` | `info` | Social OAuth sign-in |
-| `auth.sso.signin` | `session` | `info` | SSO SAML/OIDC sign-in |
-| `session.revoked` | `session` | `info` | Session explicitly revoked |
-| `device.trusted` | `device` | `info` | Device marked as trusted |
-| `user.banned` | `user` | `warning` | User account banned |
-| `account.locked` | `account` | `critical` | Account locked out after repeated failures |
-| `apikey.created` | `apikey` | `info` | API key created |
-| `apikey.revoked` | `apikey` | `warning` | API key revoked |
+The action names come from the `hook` package. Some of the common ones:
+
+| Action | Trigger |
+|--------|---------|
+| `auth.signup`, `auth.signin`, `auth.signout`, `auth.refresh` | Account and session flows |
+| `auth.password_reset`, `auth.password_reset_requested`, `auth.password_change` | Password flows |
+| `auth.email_verify`, `auth.email_verification_requested` | Email verification |
+| `auth.mfa.enroll`, `auth.mfa.challenge`, `auth.mfa.recovery_used` | MFA |
+| `auth.account_locked` | Lockout after repeated failures |
+| `auth.refresh_token_replayed` | A rotated refresh token was used again |
+| `user.create`, `user.update`, `user.delete` | User changes |
+| `session.create`, `session.revoke` | Sessions |
+| `org.create`, `org.member.add`, `org.member.remove`, `org.invitation.accept` | Organizations |
+| `rbac.role.create`, `rbac.role.assign`, `rbac.role.unassign` | Roles |
+| `admin.user.ban`, `admin.user.unlock`, `admin.impersonate` | Admin actions |
+| `device.trust`, `device.register` | Devices |
+
+See the `Action*` constants in `github.com/xraph/authsome/hook` for the full list.
## Standalone development stub
-During development, use the built-in `SlogChronicle` stub that logs audit events to `slog` without requiring a Chronicle instance:
+During development and in tests, `bridge.NewMemoryChronicle()` keeps events in memory instead of needing a Chronicle instance:
```go
-import (
- "log/slog"
- "github.com/xraph/authsome/bridge"
-)
+import "github.com/xraph/authsome/bridge"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(memory.New()),
- authsome.WithChronicle(bridge.NewSlogChronicle(slog.Default())),
+ authsome.WithWarden(wardenEngine),
+ authsome.WithChronicle(bridge.NewMemoryChronicle()),
)
```
-The stub logs a structured message for each event:
-
-```
-INFO authsome audit action=auth.signin resource=session resource_id=ases_01j... actor_id=ausr_01j... tenant=aorg_01j... outcome=success severity=info
-```
+You can also run a real Chronicle on its in-memory store, as [Getting Started](/docs/getting-started) does.
## Custom Chronicle implementation
@@ -149,10 +146,7 @@ func (c *BigQueryChronicle) Record(ctx context.Context, event *bridge.AuditEvent
}
// Register:
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- authsome.WithChronicle(&BigQueryChronicle{...}),
-)
+authsome.WithChronicle(&BigQueryChronicle{...}),
```
## Asynchronous recording
@@ -163,7 +157,7 @@ The Chronicle bridge's `Record` method is called synchronously within the auth o
type AsyncChronicle struct {
inner bridge.Chronicle
queue chan *bridge.AuditEvent
- logger *slog.Logger
+ logger *slog.Logger // or any logger you use
}
func NewAsyncChronicle(inner bridge.Chronicle) *AsyncChronicle {
@@ -181,7 +175,7 @@ func (ac *AsyncChronicle) Record(ctx context.Context, event *bridge.AuditEvent)
case ac.queue <- event:
return nil
default:
- // Queue full — log and drop rather than blocking the auth path.
+ // Queue full: log and drop rather than block the auth path.
ac.logger.Warn("chronicle queue full, dropping event", "action", event.Action)
return nil
}
diff --git a/docs/content/docs/bridges/dispatch.mdx b/docs/content/docs/bridges/dispatch.mdx
index 1f886a52..15889074 100644
--- a/docs/content/docs/bridges/dispatch.mdx
+++ b/docs/content/docs/bridges/dispatch.mdx
@@ -1,9 +1,9 @@
---
title: Dispatch Bridge
-description: Background job processing — offloads async tasks like email sending and session cleanup to Dispatch.
+description: The Dispatcher bridge to Dispatch, and what uses it today.
---
-The Dispatch bridge connects Authsome to the Dispatch background job extension. When configured, Authsome can offload long-running or retry-sensitive operations — bulk email delivery, session cleanup sweeps, expired token purges — to Dispatch's durable job queue rather than executing them in-process synchronously.
+The Dispatch bridge connects Authsome to [Dispatch](https://github.com/xraph/dispatch)'s job queue. Today only the `notification` plugin looks at it: when a dispatcher is configured, the plugin sends its notifications asynchronously. The core engine doesn't enqueue anything, so session cleanup and retention run in-process whether or not Dispatch is configured.
## Interface
@@ -23,12 +23,15 @@ import (
"github.com/xraph/authsome"
"github.com/xraph/authsome/bridge/dispatchadapter"
"github.com/xraph/dispatch"
+ "github.com/xraph/dispatch/engine"
)
-// Build the Dispatch engine (see Dispatch docs for full setup).
-dispatchEng, err := dispatch.New(
- dispatch.WithStore(dispatchStore),
-)
+// Build the Dispatch engine (see Dispatch's docs for stores and workers).
+d, err := dispatch.New(dispatch.WithStore(dispatchStore))
+if err != nil {
+ log.Fatal(err)
+}
+dispatchEng, err := engine.Build(d)
if err != nil {
log.Fatal(err)
}
@@ -37,51 +40,20 @@ if err != nil {
dispatchBridge := dispatchadapter.New(dispatchEng)
// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithDispatcher(dispatchBridge),
)
```
-## Job types enqueued by Authsome
-
-When a Dispatch bridge is configured, Authsome enqueues the following job types:
-
-| Job type | Trigger | Payload |
-|----------|---------|---------|
-| `authsome.session.cleanup` | Periodic (engine scheduler) | `{"app_id": "..."}` |
-| `authsome.verification.expire` | After verification TTL | `{"token": "..."}` |
-| `authsome.password_reset.expire` | After reset TTL | `{"token": "..."}` |
-| `authsome.webhook.deliver` | Auth event emitted | Webhook event JSON |
-
-### Session cleanup
-
-Without Dispatch, Authsome performs session cleanup synchronously during sign-out or on a timer. With Dispatch, the cleanup job is enqueued and executed by Dispatch workers at controlled concurrency, preventing cleanup sweeps from competing with live auth operations:
-
-```go
-// Authsome internally calls:
-dispatcher.Enqueue(ctx, "authsome.session.cleanup", payload)
-```
-
-### Webhook delivery (without Relay)
-
-If you do not use the Relay bridge but still want retry semantics on webhook delivery, Dispatch can handle the retry queue:
-
-```go
-// Authsome enqueues webhook delivery attempts through Dispatch:
-dispatcher.Enqueue(ctx, "authsome.webhook.deliver", eventPayload)
-```
-
-Dispatch handles retries with exponential backoff. Failed deliveries are moved to a dead-letter queue after the configured maximum retry count.
-
-## Scheduled jobs
+## What Authsome sends through it
-`Schedule` allows jobs to run at a specific future time. Authsome uses this for expiry-based operations:
+| Caller | Behaviour with a dispatcher |
+|--------|-----------------------------|
+| `notification` plugin | Sends notifications asynchronously (the same as `Async: true` in its config) |
-```go
-// Schedule verification token expiry 24 hours from now:
-dispatcher.Schedule(ctx, "authsome.verification.expire", payload, time.Now().Add(24*time.Hour))
-```
+Nothing else calls `Enqueue` or `Schedule`. Your own plugins can reach the bridge through the plugin engine's `Dispatcher()` and enqueue jobs there; the adapter forwards the job name and payload to Dispatch.
## Standalone development stub
@@ -90,12 +62,13 @@ During development, use the built-in `NoopDispatcher` stub:
```go
import "github.com/xraph/authsome/bridge"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithDispatcher(bridge.NewNoopDispatcher()),
)
```
-The `NoopDispatcher` returns `ErrDispatchNotAvailable` for all operations. When Dispatch is not configured, Authsome performs cleanup operations synchronously in-process. For local development and low-traffic applications, this is perfectly acceptable.
+`NoopDispatcher` returns `ErrDispatchNotAvailable` from every call.
diff --git a/docs/content/docs/bridges/herald.mdx b/docs/content/docs/bridges/herald.mdx
index c7bd18ad..08a6ecc3 100644
--- a/docs/content/docs/bridges/herald.mdx
+++ b/docs/content/docs/bridges/herald.mdx
@@ -3,7 +3,7 @@ title: Herald Bridge
description: Advanced multi-channel notification delivery via Herald, replacing the separate Mailer and SMS bridges.
---
-The Herald bridge connects Authsome to the Herald notification system. Herald provides unified, multi-channel notification delivery with template management, user preference handling, locale support, and async delivery. When configured, Herald replaces the separate Mailer and SMSSender bridges — all transactional notification delivery is routed through a single integration point.
+The Herald bridge connects Authsome to the Herald notification system. Herald provides unified, multi-channel notification delivery with template management, user preference handling, locale support, and async delivery. When configured, Herald replaces the separate Mailer and SMSSender bridges: all transactional notification delivery is routed through a single integration point.
## Interface
@@ -68,8 +68,7 @@ import (
// Build the Herald engine (see Herald docs for full setup).
heraldEng, err := herald.New(
herald.WithStore(heraldStore),
- herald.WithMailer(resendMailer),
- herald.WithSMS(twilioSMS),
+ // channels and providers are configured in Herald; see its docs
)
if err != nil {
log.Fatal(err)
@@ -80,7 +79,8 @@ heraldBridge := heraldadapter.New(heraldEng)
// Register with Authsome.
// When WithHerald is set, it takes precedence over WithMailer and WithSMSSender.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithPlugin(password.New()),
authsome.WithPlugin(mfa.New()),
@@ -133,7 +133,8 @@ import (
"github.com/xraph/authsome/bridge"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithHerald(bridge.NewNoopHerald(slog.Default())),
)
diff --git a/docs/content/docs/bridges/keysmith.mdx b/docs/content/docs/bridges/keysmith.mdx
index 345b4814..8e651548 100644
--- a/docs/content/docs/bridges/keysmith.mdx
+++ b/docs/content/docs/bridges/keysmith.mdx
@@ -1,6 +1,6 @@
---
title: Keysmith Bridge
-description: Centralized API key management — delegates key operations to Keysmith for rate limiting, scoping, and rotation.
+description: Centralized API key management: delegates key operations to Keysmith for rate limiting, scoping, and rotation.
---
The Keysmith bridge connects Authsome to the Keysmith API key management extension. When configured, all API key creation, validation, and revocation operations are delegated to Keysmith, which provides a richer feature set than Authsome's built-in API key store: rate limiting per key, scope enforcement, key rotation with grace periods, usage analytics, and multi-tenant isolation.
@@ -50,18 +50,17 @@ import (
)
// Build the Keysmith engine.
-keysmithEng, err := keysmith.New(
+keysmithEng, err := keysmith.NewEngine(
keysmith.WithStore(keysmithStore),
- keysmith.WithRateLimit(keysmith.RateLimitConfig{
- RequestsPerMinute: 1000,
- }),
+ // keysmith.WithRateLimiter(...), WithHasher, WithLogger and others; see Keysmith's docs
)
if err != nil {
log.Fatal(err)
}
// Register with Authsome using the first-class option.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithKeysmith(keysmithEng),
)
@@ -82,7 +81,8 @@ import (
"github.com/xraph/authsome/bridge/keysmithadapter"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithKeyManager(keysmithadapter.New(keysmithEng)),
)
@@ -102,16 +102,18 @@ eng, err := authsome.New(
## Scope enforcement
-When Keysmith is configured, API key validation includes scope checking. Create keys with explicit scopes:
+When Keysmith is configured, API key validation includes scope checking. With the `apikey` plugin registered, create keys with explicit scopes through `POST /v1/keys`:
-```go
-result, err := eng.CreateAPIKey(ctx, &authsome.CreateAPIKeyRequest{
- Name: "CI pipeline key",
- UserID: userID,
- Scopes: []string{"read:users", "read:sessions"},
-})
+```json
+{
+ "app_id": "aapp_...",
+ "name": "CI pipeline key",
+ "scopes": ["read:users", "read:sessions"]
+}
```
+`user_id` is optional and defaults to the caller; naming another user needs the `manage` permission on `apikey`. Service-account keys come from `eng.CreateServiceAccountAPIKey`.
+
Keysmith validates that the key's declared scopes match the required scopes for each operation. Requests from keys without the required scope are rejected with a `403 Forbidden`.
## Standalone development stub
@@ -123,10 +125,11 @@ import "github.com/xraph/authsome/bridge"
// Authsome uses NoopKeyManager internally when no KeyManager is set.
// You can also set it explicitly to be explicit:
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithKeyManager(bridge.NewNoopKeyManager()),
)
```
-When `NoopKeyManager` is active, calls to create, validate, or revoke API keys return an error. If your application does not use API keys, this is fine — no action needed.
+When `NoopKeyManager` is active, calls to create, validate, or revoke API keys return an error. If your application does not use API keys, this is fine: no action needed.
diff --git a/docs/content/docs/bridges/ledger.mdx b/docs/content/docs/bridges/ledger.mdx
index 761ce43f..7c918767 100644
--- a/docs/content/docs/bridges/ledger.mdx
+++ b/docs/content/docs/bridges/ledger.mdx
@@ -1,6 +1,6 @@
---
title: Ledger Bridge
-description: Billing and metering integration — tracks auth usage events and checks feature entitlements via Ledger.
+description: Billing and metering integration: tracks auth usage events and checks feature entitlements via Ledger.
---
The Ledger bridge connects Authsome to the Ledger billing and metering extension. When configured, Authsome records usage events (monthly active users, API key validations, MFA enrollments) to Ledger's metering system and checks feature entitlements before allowing certain operations.
@@ -26,18 +26,14 @@ import (
)
// Build the Ledger engine (see Ledger docs for full setup).
-ledgerEng, err := ledger.New(
- ledger.WithStore(ledgerStore),
-)
-if err != nil {
- log.Fatal(err)
-}
+ledgerEng := ledger.New(ledgerStore) // returns *ledger.Ledger; options follow the store
// Wrap in the Authsome adapter.
ledgerBridge := ledgeradapter.New(ledgerEng)
// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithLedger(ledgerBridge),
)
@@ -77,12 +73,13 @@ During development, use the built-in `NoopLedger` stub. The noop ledger returns
```go
import "github.com/xraph/authsome/bridge"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithLedger(bridge.NewNoopLedger()),
)
```
-The `NoopLedger` always returns `allowed=true` from `CheckEntitlement`. This is intentional — in development and testing, you want all features available. In production, the real Ledger implementation enforces plan limits.
+The `NoopLedger` always returns `allowed=true` from `CheckEntitlement`. This is intentional: in development and testing, you want all features available. In production, the real Ledger implementation enforces plan limits.
diff --git a/docs/content/docs/bridges/mailer.mdx b/docs/content/docs/bridges/mailer.mdx
index 2d786b71..59a71969 100644
--- a/docs/content/docs/bridges/mailer.mdx
+++ b/docs/content/docs/bridges/mailer.mdx
@@ -3,9 +3,9 @@ title: Mailer Bridge
description: Transactional email delivery for verification, password resets, magic links, and invitations.
---
-The Mailer bridge provides transactional email delivery for all Authsome email notifications: email address verification, password reset links, magic link sign-in, and organisation invitations. You provide the mailer implementation; Authsome provides the message content.
+The Mailer bridge sends Authsome's transactional email: address verification, password reset links, magic links and organization invitations. You provide the mailer; Authsome writes the messages.
-If you need multi-channel delivery (email + SMS + push) or template management, use the [Herald bridge](/docs/bridges/herald) instead — Herald supersedes both the Mailer and SMS bridges when configured.
+For several channels (email, SMS, push) or template management, use the [Herald bridge](/docs/bridges/herald). When Herald is configured it takes over from the Mailer and SMS bridges.
## Interface
@@ -25,55 +25,46 @@ type EmailMessage struct {
}
```
-## Setup with a Resend adapter
+## Setup with the Resend adapter
+
+`bridge/maileradapter` ships two adapters, Resend and SMTP.
```go
import (
+ "os"
+
"github.com/xraph/authsome"
"github.com/xraph/authsome/bridge/maileradapter"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithPlugin(password.New()),
- authsome.WithMailer(maileradapter.NewResend(os.Getenv("RESEND_API_KEY"),
- maileradapter.WithFromAddress("noreply@myapp.com"),
- maileradapter.WithFromName("My App"),
+ authsome.WithMailer(maileradapter.NewResendMailer(
+ os.Getenv("RESEND_API_KEY"),
+ "My App ",
)),
)
```
-## Setup with a SendGrid adapter
-
-```go
-import "github.com/xraph/authsome/bridge/maileradapter"
-
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- authsome.WithMailer(maileradapter.NewSendGrid(os.Getenv("SENDGRID_API_KEY"),
- maileradapter.WithFromAddress("noreply@myapp.com"),
- )),
-)
-```
+`NewResendMailer(apiKey, fromAddr, opts...)` also takes `WithResendHTTPClient` and `WithResendBaseURL`.
-## Setup with an SMTP adapter
+## Setup with the SMTP adapter
```go
-import "github.com/xraph/authsome/bridge/maileradapter"
-
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- authsome.WithMailer(maileradapter.NewSMTP(maileradapter.SMTPConfig{
- Host: "smtp.example.com",
- Port: 587,
- Username: os.Getenv("SMTP_USERNAME"),
- Password: os.Getenv("SMTP_PASSWORD"),
- From: "noreply@myapp.com",
- TLS: true,
- })),
-)
+authsome.WithMailer(maileradapter.NewSMTPMailer(
+ "smtp.example.com",
+ "587", // the port is a string
+ os.Getenv("SMTP_USERNAME"),
+ os.Getenv("SMTP_PASSWORD"),
+ "noreply@myapp.com",
+ maileradapter.WithSMTPTLS(true),
+)),
```
+There's no SendGrid adapter. For SendGrid, Postmark or anything else, implement `bridge.Mailer` yourself, as below.
+
## Custom implementation
Implement `bridge.Mailer` directly for any email provider:
@@ -95,7 +86,8 @@ func (m *PostmarkMailer) SendEmail(ctx context.Context, msg *bridge.EmailMessage
return err
}
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithMailer(&PostmarkMailer{
client: postmark.NewClient(os.Getenv("POSTMARK_TOKEN"), ""),
@@ -104,40 +96,33 @@ eng, err := authsome.New(
)
```
-## Emails sent by Authsome
+## What sends through the mailer
-| Email | Trigger | Subject |
-|-------|---------|---------|
-| Email verification | User signs up | "Verify your email address" |
-| Password reset | User requests reset | "Reset your password" |
-| Magic link | User requests magic link sign-in | "Sign in to [App Name]" |
-| Organisation invitation | Admin invites member | "You've been invited to [Org Name]" |
-| Welcome email | User completes registration | "Welcome to [App Name]" |
+The core engine sends no email itself. The mailer bridge is used by:
-The subject lines and HTML/text body content are generated by Authsome's built-in templates. If you want fully customised email content and templates, use the [Herald bridge](/docs/bridges/herald) which provides a full template management system.
+| Sender | Emails |
+|--------|--------|
+| `email` plugin | A welcome email after sign-up, and a "verify your email" message when a user is created. That message links to `{BaseURL}/verify-email` but doesn't include the verification code |
+| Your own code | Anything you send with `eng.Mailer().SendEmail(...)` |
+
+Other mail goes elsewhere. Verification codes and password resets are delivered by the `notification` plugin through a [Herald bridge](/docs/bridges/herald). Magic links go through `magiclink.Config.Mailer`, which is a different interface.
## Standalone development stub
-During development, use the built-in `NoopMailer` that logs all outgoing emails at debug level:
+During development, `bridge.NewNoopMailer(logger)` logs each email at debug level instead of sending it. It takes a go-utils `log.Logger`:
```go
import (
- "log/slog"
+ "github.com/xraph/go-utils/log"
+
"github.com/xraph/authsome/bridge"
)
-eng, err := authsome.New(
- authsome.WithStore(memory.New()),
- authsome.WithMailer(bridge.NewNoopMailer(slog.Default())),
-)
+authsome.WithMailer(bridge.NewNoopMailer(log.NewDevelopmentLogger())),
```
-Log output:
-
-```
-DEBUG authsome mailer (noop) to=user@example.com subject=Verify your email address
-```
+Or run Mailpit and point the SMTP adapter at `localhost:1025`, as the [Full Example](/docs/guides/full-example) does.
-When no Mailer bridge is configured (and no Herald bridge either), Authsome's password and magic link plugins will still function but will not send email. Users will not receive verification emails, password reset links, or magic links. Always configure a mailer in production.
+Without a mailer, the `email` plugin sends nothing and logs nothing. Verification codes and password resets need the `notification` plugin with Herald regardless of the mailer.
diff --git a/docs/content/docs/bridges/metrics.mdx b/docs/content/docs/bridges/metrics.mdx
index 59cbb33f..ec75828d 100644
--- a/docs/content/docs/bridges/metrics.mdx
+++ b/docs/content/docs/bridges/metrics.mdx
@@ -1,9 +1,9 @@
---
title: Metrics Bridge
-description: Observability integration — emits authentication metrics to Prometheus, OpenTelemetry, or any collector backend.
+description: Observability integration: emits authentication metrics to Prometheus, OpenTelemetry, or any collector backend.
---
-The Metrics bridge connects Authsome to your observability stack. When configured, Authsome emits metrics for every authentication event: event counts by action and outcome, sign-in latency, session counts, active user gauges, and more. The bridge is a thin interface — you wire it to Prometheus, OpenTelemetry, Datadog, or any other metrics backend.
+The Metrics bridge connects Authsome to your observability stack. When configured, Authsome emits metrics for every authentication event: event counts by action and outcome, sign-in latency, session counts, active user gauges, and more. The bridge is a thin interface: you wire it to Prometheus, OpenTelemetry, Datadog, or any other metrics backend.
## Interface
@@ -86,7 +86,8 @@ func (c *PrometheusCollector) IncrementGauge(name, tenant string, delta int) {
}
// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithMetrics(NewPrometheusCollector(prometheus.DefaultRegisterer)),
)
@@ -175,4 +176,4 @@ The following events and gauges are emitted when the Metrics bridge is configure
## When no metrics bridge is configured
-Without a metrics bridge, Authsome performs all operations normally. No metrics overhead is incurred — the bridge check is a single nil pointer check. Configuring metrics is purely additive.
+Without a metrics bridge, Authsome performs all operations normally. No metrics overhead is incurred: the bridge check is a single nil pointer check. Configuring metrics is purely additive.
diff --git a/docs/content/docs/bridges/overview.mdx b/docs/content/docs/bridges/overview.mdx
index d08cb11b..4377f540 100644
--- a/docs/content/docs/bridges/overview.mdx
+++ b/docs/content/docs/bridges/overview.mdx
@@ -3,62 +3,53 @@ title: Bridges
description: Optional integration adapters that connect Authsome to the broader Forgery ecosystem.
---
-Bridges are Authsome's extension point for optional third-party and Forgery ecosystem integrations. Each bridge is a thin Go interface defined in the `github.com/xraph/authsome/bridge` package. You implement the interface yourself, use one of the provided adapter packages, or use the built-in no-op stubs for local development.
+Bridges connect Authsome to other systems, mostly other Forgery libraries. Each bridge is a small Go interface in `github.com/xraph/authsome/bridge`. You implement it yourself, use an adapter package, or use a built-in stub during development.
-When a bridge is not configured, Authsome uses a sensible default: audit events go to `slog`, webhook delivery is dropped with a debug log, emails are logged and discarded. Your application always starts and runs — bridges are opt-in enhancements, not requirements.
+Two bridges are required. `NewEngine` fails without Chronicle (`WithChronicle`) and without Warden (`WithWarden`). The others are optional: when one isn't configured, Authsome uses a no-op and skips that work, so no webhooks go out and no email is sent.
## The bridge pattern
Each bridge follows the same pattern:
-1. **Interface** — defined in `github.com/xraph/authsome/bridge`. Narrow by design (1–4 methods).
-2. **Adapter package** — a thin translation layer in `github.com/xraph/authsome/bridge/adapter` that wraps the real extension client.
-3. **Stub** — a no-op implementation in `bridge/stubs.go` used as a fallback when the bridge is not configured.
-4. **Option** — a `With*` function on the engine for registering the bridge.
+1. An interface in `github.com/xraph/authsome/bridge`, a few methods at most.
+2. An adapter package, `github.com/xraph/authsome/bridge/adapter`, that wraps the real client.
+3. A no-op stub in `bridge/stubs.go`, used when the bridge isn't configured.
+4. A `With*` option on the engine.
```
-authsome/
-├── bridge/
-│ ├── chronicle.go ← Chronicle interface + AuditEvent type
-│ ├── relay.go ← EventRelay interface + WebhookEvent type
-│ ├── herald.go ← Herald interface
-│ ├── vault.go ← Vault interface
-│ ├── dispatch.go ← Dispatcher interface
-│ ├── ledger.go ← Ledger interface
-│ ├── warden.go ← Authorizer interface
-│ ├── keysmith.go ← KeyManager interface
-│ ├── mailer.go ← Mailer interface
-│ ├── sms.go ← SMSSender interface
-│ ├── metrics.go ← MetricsCollector interface
-│ └── stubs.go ← No-op implementations for all bridges
-└── bridge/
- ├── chronicleadapter/ ← Wraps *chronicle.Engine
- ├── relayadapter/ ← Wraps *relay.Engine
- ├── heraldadapter/ ← Wraps *herald.Engine
- ├── vaultadapter/ ← Wraps *vault.Vault
- ├── dispatchadapter/ ← Wraps *dispatch.Engine
- ├── ledgeradapter/ ← Wraps *ledger.Engine
- ├── wardenadapter/ ← Wraps *warden.Engine
- ├── keysmithadapter/ ← Wraps *keysmith.Engine
- ├── maileradapter/ ← Wraps email providers
- └── smsadapter/ ← Wraps SMS providers
+bridge/
+ chronicle.go, relay.go, herald.go, vault.go, dispatch.go, ledger.go,
+ warden.go, keysmith.go, mailer.go, sms.go, metrics.go interfaces
+ stubs.go no-op implementations
+ memory_chronicle.go, memory_relay.go in-memory versions for tests
+ chronicleadapter/ wraps a chronicle.Emitter
+ relayadapter/ wraps *relay.Relay
+ heraldadapter/ wraps *herald.Herald
+ vaultadapter/ wraps Vault's secret, flag and config services
+ dispatchadapter/ wraps *dispatch.Engine
+ ledgeradapter/ wraps *ledger.Ledger
+ wardenadapter/ wraps *warden.Engine
+ keysmithadapter/ wraps *keysmith.Engine
+ metricsadapter/ wraps forge.Metrics
+ maileradapter/ Resend and SMTP
+ smsadapter/ Twilio
```
## Available bridges
| Bridge | Option | Interface | What it does |
|--------|--------|-----------|-------------|
-| **Chronicle** | `WithChronicle` | `bridge.Chronicle` | Records all auth events to an audit trail |
-| **Relay** | `WithEventRelay` | `bridge.EventRelay` | Delivers webhook payloads to subscribers |
-| **Herald** | `WithHerald` | `bridge.Herald` | Multi-channel notifications (email, SMS, push) |
-| **Vault** | `WithVault` | `bridge.Vault` | Secrets management and feature flags |
-| **Dispatch** | `WithDispatcher` | `bridge.Dispatcher` | Background job queue for async tasks |
-| **Ledger** | `WithLedger` | `bridge.Ledger` | Billing metering and entitlement checks |
-| **Warden** | `WithWarden` / `WithAuthorizer` | `bridge.Authorizer` | Advanced RBAC and policy evaluation |
-| **Keysmith** | `WithKeysmith` / `WithKeyManager` | `bridge.KeyManager` | Centralized API key management |
-| **Mailer** | `WithMailer` | `bridge.Mailer` | Transactional email delivery |
-| **SMS** | `WithSMSSender` | `bridge.SMSSender` | SMS delivery for OTP and MFA codes |
-| **Metrics** | `WithMetrics` | `bridge.MetricsCollector` | Observability metrics export |
+| Chronicle (required) | `WithChronicle` | `bridge.Chronicle` | Records auth events to an audit trail |
+| Relay | `WithEventRelay` | `bridge.EventRelay` | Delivers webhook payloads to subscribers |
+| Herald | `WithHerald` | `bridge.Herald` | Multi-channel notifications (email, SMS, push) |
+| Vault | `WithVault` | `bridge.Vault` | Secrets management and feature flags |
+| Dispatch | `WithDispatcher` | `bridge.Dispatcher` | Background job queue for async tasks |
+| Ledger | `WithLedger` | `bridge.Ledger` | Billing metering and entitlement checks |
+| Warden (required) | `WithWarden` (and optionally `WithAuthorizer`) | `*warden.Engine`, `bridge.Authorizer` | RBAC and policy evaluation |
+| Keysmith | `WithKeysmith` / `WithKeyManager` | `bridge.KeyManager` | Centralized API key management |
+| Mailer | `WithMailer` | `bridge.Mailer` | Transactional email delivery |
+| SMS | `WithSMSSender` | `bridge.SMSSender` | SMS delivery for OTP and MFA codes |
+| Metrics | `WithMetrics` | `bridge.MetricsCollector` | Observability metrics export |
## Registering bridges
@@ -72,24 +63,25 @@ import (
"github.com/xraph/authsome/bridge/maileradapter"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(pgStore),
+ authsome.WithWarden(wardenEngine),
authsome.WithPlugin(password.New()),
- // Chronicle: audit all auth events.
- authsome.WithChronicle(chronicleadapter.New(chronicleClient)),
+ // Chronicle: audit all auth events. Takes a chronicle.Emitter.
+ authsome.WithChronicle(chronicleadapter.New(chronicleEngine)),
// Relay: deliver webhook events reliably.
- authsome.WithEventRelay(relayadapter.New(relayClient)),
+ authsome.WithEventRelay(relayadapter.New(relayEngine)),
// Mailer: send verification and reset emails.
- authsome.WithMailer(maileradapter.NewResend(os.Getenv("RESEND_API_KEY"))),
+ authsome.WithMailer(maileradapter.NewResendMailer(os.Getenv("RESEND_API_KEY"), "noreply@myapp.com")),
)
```
## Using the Forgery first-class bridge options
-For Warden and Keysmith, Authsome provides dedicated `With*` options that accept the concrete extension engine directly — you do not need to import the adapter package separately:
+For Warden and Keysmith, `WithWarden` and `WithKeysmith` take the engine itself, so you don't import the adapter:
```go
import (
@@ -97,11 +89,12 @@ import (
"github.com/xraph/keysmith"
)
-wardenEng, _ := warden.New(...)
-keysmithEng, _ := keysmith.New(...)
+wardenEng, err := warden.NewEngine(warden.WithStore(wardenStore))
+keysmithEng, err := keysmith.NewEngine(/* ... */)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(pgStore),
+ authsome.WithChronicle(chronicleBridge),
// WithWarden accepts *warden.Engine directly.
authsome.WithWarden(wardenEng),
// WithKeysmith accepts *keysmith.Engine directly.
@@ -109,23 +102,30 @@ eng, err := authsome.New(
)
```
-Both options internally wrap the engine in the appropriate adapter package and also set the `bridge.Authorizer` / `bridge.KeyManager` for backward compatibility.
+Both wrap the engine in its adapter and also set the `bridge.Authorizer` or `bridge.KeyManager`.
## No-op stubs for development
-When developing locally without the full Forgery stack, you can use the built-in stubs:
+For local work without the rest of the stack, use the in-memory and no-op versions. The no-op ones that log take a go-utils `log.Logger`:
```go
-import "github.com/xraph/authsome/bridge"
+import (
+ "github.com/xraph/go-utils/log"
+
+ "github.com/xraph/authsome/bridge"
+)
-eng, err := authsome.New(
+logger := log.NewDevelopmentLogger()
+
+eng, err := authsome.NewEngine(
authsome.WithStore(memory.New()),
- // Log audit events to slog instead of Chronicle.
- authsome.WithChronicle(bridge.NewSlogChronicle(slog.Default())),
- // Drop webhook events silently.
- authsome.WithEventRelay(bridge.NewNoopRelay(slog.Default())),
- // Log emails to debug instead of sending them.
- authsome.WithMailer(bridge.NewNoopMailer(slog.Default())),
+ authsome.WithWarden(wardenEng),
+ // Keep audit events in memory.
+ authsome.WithChronicle(bridge.NewMemoryChronicle()),
+ // Log webhook events and drop them.
+ authsome.WithEventRelay(bridge.NewNoopRelay(logger)),
+ // Log emails instead of sending them.
+ authsome.WithMailer(bridge.NewNoopMailer(logger)),
)
```
@@ -153,7 +153,8 @@ func (m *SendGridMailer) SendEmail(ctx context.Context, msg *bridge.EmailMessage
}
// Register with the engine.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithMailer(&SendGridMailer{
client: sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")),
diff --git a/docs/content/docs/bridges/relay.mdx b/docs/content/docs/bridges/relay.mdx
index 6bc3a63c..812b0f1d 100644
--- a/docs/content/docs/bridges/relay.mdx
+++ b/docs/content/docs/bridges/relay.mdx
@@ -1,11 +1,11 @@
---
title: Relay Bridge
-description: Reliable webhook delivery — sends auth event payloads to Relay for fan-out with retry semantics.
+description: Reliable webhook delivery: sends auth event payloads to Relay for fan-out with retry semantics.
---
The Relay bridge connects Authsome to the Relay webhook delivery extension. When configured, every auth event that triggers a webhook (sign-in, sign-up, org changes, MFA enrollment, API key creation, and more) is sent to Relay, which handles reliable fan-out to all registered subscriber endpoints with configurable retry policies.
-Without Relay, Authsome stores webhook configurations in its own database and emits delivery attempts directly. With Relay, delivery is fully offloaded — Relay owns the delivery queue, retry backoff, failure tracking, and subscriber management.
+Without Relay, Authsome stores webhook configurations in its own database and emits delivery attempts directly. With Relay, delivery is fully offloaded: Relay owns the delivery queue, retry backoff, failure tracking, and subscriber management.
## Interface
@@ -53,7 +53,8 @@ if err != nil {
relayBridge := relayadapter.New(relayEng)
// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithPlugin(password.New()),
authsome.WithEventRelay(relayBridge),
@@ -141,7 +142,8 @@ import (
"github.com/xraph/authsome/bridge"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithEventRelay(bridge.NewNoopRelay(slog.Default())),
)
@@ -150,7 +152,8 @@ eng, err := authsome.New(
You can also use an inline function adapter to inspect events in tests:
```go
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithEventRelay(bridge.EventRelayFunc(func(ctx context.Context, event *bridge.WebhookEvent) error {
t.Logf("webhook event: type=%s tenant=%s", event.Type, event.TenantID)
@@ -161,4 +164,4 @@ eng, err := authsome.New(
## Idempotency
-Every webhook event includes an `IdempotencyKey` field set to the TypeID of the entity that changed (session ID for sign-in events, user ID for user creation events, etc.). Relay uses this key to deduplicate delivery attempts — if the same event is sent twice (e.g., due to a retry after a network timeout), subscribers receive it exactly once.
+Every webhook event includes an `IdempotencyKey` field set to the TypeID of the entity that changed (session ID for sign-in events, user ID for user creation events, etc.). Relay uses this key to deduplicate delivery attempts: if the same event is sent twice (e.g., due to a retry after a network timeout), subscribers receive it exactly once.
diff --git a/docs/content/docs/bridges/sms.mdx b/docs/content/docs/bridges/sms.mdx
index d685b459..76a8b7d4 100644
--- a/docs/content/docs/bridges/sms.mdx
+++ b/docs/content/docs/bridges/sms.mdx
@@ -3,9 +3,9 @@ title: SMS Bridge
description: SMS delivery for OTP codes, MFA verification, and phone number verification.
---
-The SMS bridge provides SMS delivery for all Authsome phone-based flows: SMS-based MFA one-time passwords, phone number verification during sign-up, and phone-based magic link delivery. You provide the SMS provider implementation; Authsome provides the message content.
+The SMS bridge sends Authsome's text messages: phone sign-in codes from the `phone` plugin and SMS codes for MFA. You provide the sender; Authsome writes the messages.
-If you need multi-channel delivery (email + SMS + push) or template management, use the [Herald bridge](/docs/bridges/herald) instead — Herald supersedes both the Mailer and SMS bridges when configured.
+For several channels or template management, use the [Herald bridge](/docs/bridges/herald). When Herald is configured it takes over from the Mailer and SMS bridges.
## Interface
@@ -22,19 +22,26 @@ type SMSMessage struct {
}
```
-## Setup with a Twilio adapter
+## Setup with the Twilio adapter
+
+`bridge/smsadapter` ships one adapter, for Twilio:
```go
import (
+ "os"
+
"github.com/xraph/authsome"
"github.com/xraph/authsome/bridge/smsadapter"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/phone"
)
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
- authsome.WithPlugin(mfa.New(mfa.WithSMSOTP())),
+ authsome.WithPlugin(mfa.New(mfa.Config{Issuer: "My App"})),
authsome.WithPlugin(phone.New()),
- authsome.WithSMSSender(smsadapter.NewTwilio(
+ authsome.WithSMSSender(smsadapter.NewTwilioSender(
os.Getenv("TWILIO_ACCOUNT_SID"),
os.Getenv("TWILIO_AUTH_TOKEN"),
os.Getenv("TWILIO_FROM_NUMBER"), // E.164, e.g. "+12015551234"
@@ -42,19 +49,7 @@ eng, err := authsome.New(
)
```
-## Setup with AWS SNS
-
-```go
-import "github.com/xraph/authsome/bridge/smsadapter"
-
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- authsome.WithSMSSender(smsadapter.NewSNS(awsSession,
- smsadapter.WithSNSSenderID("MyApp"),
- smsadapter.WithSNSMessageType("Transactional"),
- )),
-)
-```
+The phone plugin uses the engine's SMS sender unless you give it one in `phone.Config{SMSSender: ...}`. There's no AWS SNS adapter; implement `bridge.SMSSender` for SNS or any other provider.
## Custom implementation
@@ -71,7 +66,8 @@ func (s *VonageSMSSender) SendSMS(ctx context.Context, msg *bridge.SMSMessage) e
return err
}
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithSMSSender(&VonageSMSSender{
client: vonage.NewSMSClient(vonage.CreateAuthClient(
@@ -85,17 +81,16 @@ eng, err := authsome.New(
## SMS messages sent by Authsome
-| Trigger | Message body |
-|---------|-------------|
-| MFA OTP (SMS) | "Your verification code is: 123456. Valid for 5 minutes." |
-| Phone verification | "Your phone verification code is: 654321. Valid for 10 minutes." |
-| Magic link (phone) | "Sign in to My App: https://myapp.com/auth/magic?token=..." |
+| Trigger | Message |
+|---------|---------|
+| MFA SMS code | A six-digit code, valid for 5 minutes |
+| Phone sign-in | A one-time code from the `phone` plugin, valid for its `CodeTTL` |
-The message body is generated by Authsome's built-in templates. For fully customised SMS content, use the [Herald bridge](/docs/bridges/herald).
+For your own wording, use the [Herald bridge](/docs/bridges/herald).
## Phone number format
-All phone numbers passed in `SMSMessage.To` use E.164 format: `+` followed by the country code and subscriber number, with no spaces or hyphens. Authsome normalises phone numbers to E.164 when users provide them during sign-up or phone verification.
+Send phone numbers in E.164 format: `+`, the country code and the subscriber number, with no spaces or hyphens.
Examples:
- US: `+14155551234`
@@ -104,33 +99,12 @@ Examples:
## Standalone development stub
-During development, use the built-in `NoopSMSSender` stub that returns `ErrSMSNotAvailable` for all calls:
+`bridge.NewNoopSMSSender()` returns `bridge.ErrSMSNotAvailable` from every call. To capture codes in tests, write a small sender:
```go
-import "github.com/xraph/authsome/bridge"
-
-eng, err := authsome.New(
- authsome.WithStore(memory.New()),
- authsome.WithSMSSender(bridge.NewNoopSMSSender()),
-)
-```
-
-For development testing of MFA SMS flows without a real carrier, you can use an inline function adapter that captures OTP codes:
-
-```go
-var lastOTP string
-
-eng, err := authsome.New(
- authsome.WithStore(memory.New()),
- authsome.WithSMSSender(bridge.SMSSender(bridge.SMSMessage{
- // Not a real interface, use a custom test struct
- })),
-)
-
-// Or implement a test SMS sender directly:
type TestSMSSender struct {
- Messages []*bridge.SMSMessage
mu sync.Mutex
+ Messages []*bridge.SMSMessage
}
func (s *TestSMSSender) SendSMS(_ context.Context, msg *bridge.SMSMessage) error {
@@ -139,6 +113,8 @@ func (s *TestSMSSender) SendSMS(_ context.Context, msg *bridge.SMSMessage) error
s.Messages = append(s.Messages, msg)
return nil
}
+
+authsome.WithSMSSender(&TestSMSSender{}),
```
diff --git a/docs/content/docs/bridges/vault.mdx b/docs/content/docs/bridges/vault.mdx
index 20de7b07..7efaf6f2 100644
--- a/docs/content/docs/bridges/vault.mdx
+++ b/docs/content/docs/bridges/vault.mdx
@@ -3,7 +3,7 @@ title: Vault Bridge
description: Secret management and feature flag integration via the Vault extension.
---
-The Vault bridge connects Authsome to the Vault secrets management extension. When configured, Authsome can retrieve sensitive configuration values (OAuth client secrets, signing keys, SMTP credentials) from Vault's encrypted storage rather than from environment variables or hardcoded configuration. Vault also provides feature flag evaluation, allowing you to gate auth flows behind flags at runtime.
+The Vault bridge gives your code access to Vault's secrets, feature flags and config through `eng.Vault()`. Authsome itself doesn't read anything through it today: no core code or plugin calls the bridge, so it's a convenience for your own startup code and plugins. Use it to fetch OAuth client secrets or SMTP credentials from Vault instead of the environment.
## Interface
@@ -20,28 +20,22 @@ type Vault interface {
## Setup with the Vault adapter
+The adapter wraps Vault's secret, flag and config services. Any of them can be nil; a nil service makes its methods return `bridge.ErrVaultNotAvailable`.
+
```go
import (
"github.com/xraph/authsome"
"github.com/xraph/authsome/bridge/vaultadapter"
- "github.com/xraph/vault"
)
-// Build the Vault engine (see Vault docs for full setup).
-vaultEng, err := vault.New(
- vault.WithStore(vaultStore),
- vault.WithEncryptionKey(encryptionKey),
- vault.WithAppID("myapp"),
+// secretSvc, flagSvc and configSvc come from your Vault setup
+// (*secret.Service, *flag.Service and *config.Service in github.com/xraph/vault).
+vaultBridge := vaultadapter.New(secretSvc, flagSvc, configSvc,
+ vaultadapter.WithAppID("myapp"),
)
-if err != nil {
- log.Fatal(err)
-}
-
-// Wrap in the Authsome adapter.
-vaultBridge := vaultadapter.New(vaultEng)
-// Register with Authsome.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithVault(vaultBridge),
)
@@ -54,31 +48,34 @@ eng, err := authsome.New(
Instead of passing OAuth client secrets directly in the plugin constructor, retrieve them from Vault at startup:
```go
-githubClientID, err := vaultClient.GetSecret(ctx, "oauth.github.client_id")
+githubClientID, err := vaultBridge.GetSecret(ctx, "oauth.github.client_id")
if err != nil {
log.Fatal(err)
}
-githubClientSecret, err := vaultClient.GetSecret(ctx, "oauth.github.client_secret")
+githubClientSecret, err := vaultBridge.GetSecret(ctx, "oauth.github.client_secret")
if err != nil {
log.Fatal(err)
}
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(pgStore),
authsome.WithVault(vaultBridge),
- authsome.WithPlugin(social.New(
- social.WithGitHub(
- string(githubClientID),
- string(githubClientSecret),
- "https://myapp.com/v1/auth/social/github/callback",
- ),
- )),
+ authsome.WithPlugin(social.New(social.Config{
+ Providers: []social.Provider{
+ social.NewGitHubProvider(social.ProviderConfig{
+ ClientID: string(githubClientID),
+ ClientSecret: string(githubClientSecret),
+ RedirectURL: "https://myapp.com/v1/social/github/callback",
+ }),
+ },
+ })),
)
```
### Feature flags on auth flows
-Authsome reads feature flags from Vault when the bridge is configured. You can gate authentication features behind flags for gradual rollout:
+Your own handlers and plugins can check Vault flags through the engine:
```go
// In your auth plugin or middleware, check a Vault feature flag:
@@ -89,10 +86,10 @@ if eng.Vault().IsFeatureEnabled(ctx, "passkey_registration_enabled") {
### Dynamic configuration
-Retrieve runtime configuration values (session token TTL, rate limit thresholds, allowed callback URLs) from Vault's config store, enabling changes without redeployment:
+Read config values the same way. Authsome won't pick them up on its own, so apply them yourself:
```go
-sessionTTLStr, err := vaultClient.GetConfig(ctx, "auth.session_ttl")
+sessionTTLStr, err := vaultBridge.GetConfig(ctx, "auth.session_ttl")
if err == nil {
sessionTTL, _ = time.ParseDuration(sessionTTLStr)
}
@@ -105,12 +102,13 @@ During development, use the built-in `NoopVault` stub:
```go
import "github.com/xraph/authsome/bridge"
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithVault(bridge.NewNoopVault()),
)
```
-The `NoopVault` returns `ErrVaultNotAvailable` for all secret and config operations, and returns `false` for all feature flag checks. If your application relies on secrets retrieved from Vault at startup (OAuth credentials, signing keys), it will fail to start with the noop stub. Use real Vault in any environment where secrets must be resolved.
+`NoopVault` returns `ErrVaultNotAvailable` for secret and config calls and `false` for every flag. If your startup code reads secrets through the bridge, it fails with the stub.
diff --git a/docs/content/docs/bridges/warden.mdx b/docs/content/docs/bridges/warden.mdx
index 011f6b9b..1ac315e2 100644
--- a/docs/content/docs/bridges/warden.mdx
+++ b/docs/content/docs/bridges/warden.mdx
@@ -1,16 +1,9 @@
---
title: Warden Bridge
-description: Advanced RBAC engine — delegates permission checks to Warden for richer RBAC, ReBAC, and ABAC policy evaluation.
+description: The required authorization engine. Authsome's roles, permissions and permission checks all go through Warden.
---
-The Warden bridge connects Authsome to the Warden authorization engine. When configured, Authsome's RBAC permission checks are delegated to Warden, which supports more expressive authorization models: full RBAC with inheritance, Relationship-Based Access Control (ReBAC), and Attribute-Based Access Control (ABAC) — all in a single policy engine.
-
-Without Warden, Authsome evaluates permissions using its built-in RBAC store (`rbac.Store`), which supports role-permission assignments and a single level of role hierarchy. Warden unlocks policies like:
-
-- "Allow access if the user owns the resource"
-- "Allow access if the user is a member of the resource's parent organisation"
-- "Deny access during weekends regardless of role"
-- "Allow if user has role `admin` in the requesting tenant AND is in the EMEA region"
+Warden is Authsome's authorization engine, and it's required: `NewEngine` returns an error without `WithWarden`. Roles, permissions, role assignments and permission checks (`eng.HasPermission`, `middleware.RequirePermission`) all go through Warden's RBAC, ReBAC and ABAC evaluation.
## Interface
@@ -34,106 +27,45 @@ type AuthzResult struct {
}
```
-## Setup with the Warden engine (recommended)
-
-Authsome provides a first-class `WithWarden` option that accepts the concrete `*warden.Engine` directly. This is the recommended approach — it provides Warden's full RBAC/ReBAC/ABAC evaluation and also satisfies the `bridge.Authorizer` interface for backward compatibility:
+## Setup
```go
import (
- "github.com/xraph/authsome"
"github.com/xraph/warden"
- "github.com/xraph/authsome/store/postgres"
+ wmemory "github.com/xraph/warden/store/memory"
+
+ "github.com/xraph/authsome"
)
-// Build the Warden engine.
-wardenEng, err := warden.New(
- warden.WithStore(wardenStore),
- warden.WithPolicy(warden.PolicyOPA), // or PolicyCasbin, PolicyCEL
+wardenEng, err := warden.NewEngine(
+ warden.WithStore(wmemory.New()), // or one of Warden's persistent stores
)
if err != nil {
log.Fatal(err)
}
-// Register with Authsome using the first-class option.
-eng, err := authsome.New(
+eng, err := authsome.NewEngine(
authsome.WithStore(pgStore),
+ authsome.WithChronicle(chronicleBridge),
authsome.WithWarden(wardenEng),
)
```
-Internally, `WithWarden` calls:
-
-```go
-e.warden_ = wardenEng
-e.authorizer = wardenadapter.New(wardenEng)
-```
+`WithWarden` also sets the `bridge.Authorizer` to `wardenadapter.New(wardenEng)`. Other Warden options include `WithCache`, `WithEvaluator`, `WithLogger` and `WithConfig`; see Warden's documentation.
-## Setup with the generic Authorizer interface
+## A custom Authorizer
-If you want to integrate a different authorization engine, implement `bridge.Authorizer` directly:
+`WithAuthorizer` replaces the `bridge.Authorizer` that `WithWarden` set, for code paths that check through the bridge. It doesn't replace Warden: you still pass `WithWarden`, because role and permission storage live there.
```go
-import (
- "github.com/xraph/authsome"
- "github.com/xraph/authsome/bridge/wardenadapter"
-)
-
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- authsome.WithAuthorizer(wardenadapter.New(wardenEng)),
-)
+authsome.WithWarden(wardenEng),
+authsome.WithAuthorizer(myAuthorizer), // implements bridge.Authorizer
```
-## How Authsome uses the Authorizer
-
-Authsome calls the Authorizer bridge for:
-
-1. **RBAC permission checks** — Before allowing any `HasPermission` evaluation on the RBAC service, the authorizer is called if Warden is configured.
-2. **API endpoint access control** — Auth-protected management routes (user ban/unban, RBAC management) check the authorizer.
-3. **Plugin-level checks** — Plugins that perform sensitive operations (e.g., the organization plugin before allowing member removal) can call the authorizer.
-
-When the Authorizer is not configured (nil), Authsome falls back to its built-in RBAC store evaluation.
+## How Authsome uses Warden
-## The NoopAuthorizer
-
-During development, use the built-in `NoopAuthorizer` that always allows all requests:
-
-```go
-import "github.com/xraph/authsome/bridge"
-
-eng, err := authsome.New(
- authsome.WithStore(memory.New()),
- authsome.WithAuthorizer(bridge.NewNoopAuthorizer()),
-)
-```
-
-
-The `NoopAuthorizer` always returns `allowed: true` with the reason `"standalone mode: always allowed"`. Never use it in production. In production, either configure Warden or rely on Authsome's built-in RBAC (which evaluates real role and permission assignments from the database).
-
-
-## Warden policy example
-
-With Warden's OPA backend, you can write policies that express complex authorization logic:
-
-```text
-package authsome
-
-import future.keywords.if
-
-default allow = false
-
-# Allow if user has the required role in the tenant.
-allow if {
- role := data.roles[input.subject][input.tenant]
- action_allowed(role, input.action, input.resource)
-}
-
-# Allow admins everything.
-action_allowed("admin", _, _) = true
-
-# Allow editors to write but not delete.
-action_allowed("editor", "read", _) = true
-action_allowed("editor", "write", _) = true
-```
+- `eng.HasPermission(ctx, userID, action, resource)` and the `middleware.RequirePermission` guard check permissions in Warden.
+- Role and permission management on the engine (`CreateRole`, `AssignUserRole`, `AddPermission` and the rest) writes to Warden's store.
+- `WithBootstrap` seeds the default roles into Warden when the engine starts.
-Refer to the [Warden documentation](/docs/guides/forge-extension) for the full policy language reference.
+Policies themselves are written in Warden. See Warden's documentation for its policy language and stores.
diff --git a/docs/content/docs/concepts/configuration.mdx b/docs/content/docs/concepts/configuration.mdx
index e7ecab39..0b404cbf 100644
--- a/docs/content/docs/concepts/configuration.mdx
+++ b/docs/content/docs/concepts/configuration.mdx
@@ -3,7 +3,7 @@ title: Configuration
description: All configuration options for the Authsome engine, sessions, passwords, rate limiting, and lockout.
---
-The root `authsome` package defines the `Config` struct, `DefaultConfig()` defaults, and the `Option` functions for constructing an engine. All configuration is passed at construction time via `authsome.New(opts...)`.
+The root `authsome` package defines the `Config` struct, `DefaultConfig()` defaults, and the `Option` functions for constructing an engine. You pass all of it to `authsome.NewEngine(opts...)`. The engine also needs `WithStore`, `WithChronicle`, `WithWarden` and a token encryption key; see [Getting Started](/docs/getting-started).
## authsome.Config
@@ -13,31 +13,39 @@ The root `authsome` package defines the `Config` struct, `DefaultConfig()` defau
import "github.com/xraph/authsome"
type Config struct {
- AppID string `json:"app_id"`
- BasePath string `json:"base_path"`
- Session SessionConfig `json:"session"`
- Password PasswordConfig `json:"password"`
- RateLimit RateLimitConfig `json:"rate_limit"`
- Lockout LockoutConfig `json:"lockout"`
- DriverName string `json:"driver_name"`
- Debug bool `json:"debug"`
- DisableRoutes bool `json:"disable_routes"`
- DisableMigrate bool `json:"disable_migrate"`
+ AppID string `json:"app_id"`
+ BasePath string `json:"base_path"`
+ Session SessionConfig `json:"session"`
+ Password PasswordConfig `json:"password"`
+ RateLimit RateLimitConfig `json:"rate_limit"`
+ Lockout LockoutConfig `json:"lockout"`
+ DriverName string `json:"driver_name"`
+ Debug bool `json:"debug"`
+ DisableRoutes bool `json:"disable_routes"`
+ DisableMigrate bool `json:"disable_migrate"`
+ ProtectedResourceMetadataURL string `json:"protected_resource_metadata_url"`
+ TokenEncryption TokenEncryptionConfig `json:"token_encryption"`
+ APIKeyPepper string `json:"api_key_pepper"`
+ Retention RetentionConfig `json:"retention"`
+ Webhooks WebhookConfig `json:"webhooks"`
+ CSRF CSRFConfig `json:"csrf"`
}
```
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `AppID` | `string` | `""` | Application identifier used for scoping. Required if not set via `WithAppID`. |
-| `BasePath` | `string` | `"/v1/auth"` | URL prefix for all auto-registered auth routes. |
-| `Session` | `SessionConfig` | See below | Session token and refresh token behavior. |
-| `Password` | `PasswordConfig` | See below | Password policy and hashing algorithm. |
-| `RateLimit` | `RateLimitConfig` | See below | Per-endpoint rate limiting. |
-| `Lockout` | `LockoutConfig` | See below | Account lockout after repeated failures. |
-| `DriverName` | `string` | `""` | Grove driver name (`"pg"`, `"sqlite"`, `"mongo"`, `"memory"`). Set automatically by the extension. |
-| `Debug` | `bool` | `false` | Enables verbose logging of all engine operations. |
-| `DisableRoutes` | `bool` | `false` | When `true`, prevents automatic route registration on `Start`. |
-| `DisableMigrate` | `bool` | `false` | When `true`, prevents automatic schema migration on `Start`. |
+| Field | Default | Description |
+|-------|---------|-------------|
+| `AppID` | `""` | Default application ID |
+| `BasePath` | `"/authsome"` | The mount prefix the Forge extension uses. The standalone API registers its routes under `/v1` whatever this is |
+| `Session`, `Password`, `RateLimit`, `Lockout` | see below | |
+| `DriverName` | `""` | Grove driver name (`"pg"`, `"sqlite"`, `"mongo"`) that plugins use to pick their migrations. The Forge extension sets it; standalone, pass `WithDriverName` |
+| `Debug` | `false` | Verbose logging |
+| `DisableRoutes` | `false` | Read by the Forge extension to skip route registration |
+| `DisableMigrate` | `false` | Skip migrations on `Start` |
+| `TokenEncryption` | empty | `Keys` as `kid:hex` pairs and `Strict`. Empty falls back to `AUTHSOME_TOKEN_ENCRYPTION_KEYS` or `AUTHSOME_TOKEN_ENCRYPTION_KEY` |
+| `APIKeyPepper` | `""` | Pepper for API key hashes |
+| `Retention` | 30 days for sessions, 7 for verifications and resets, 90 for revoked refresh tokens, 1 for device and auth codes, batch 1000 | How long expired rows are kept before the sweeper removes them |
+| `Webhooks` | `VerifyTimeout: 10s` | `AllowInsecureURLs` and `VerifyTimeout` |
+| `CSRF` | on | `Enabled *bool` (nil means on) and `AllowedOrigins` |
## SessionConfig
@@ -49,6 +57,7 @@ type SessionConfig struct {
RotateRefreshToken *bool `json:"rotate_refresh_token"`
BindToIP bool `json:"bind_to_ip"`
BindToDevice bool `json:"bind_to_device"`
+ CleanupInterval time.Duration `json:"cleanup_interval"`
}
```
@@ -60,6 +69,7 @@ type SessionConfig struct {
| `RotateRefreshToken` | `*bool` | `true` | When `true`, every `Refresh` call issues a new refresh token and invalidates the old one. Set to `false` to reuse the same refresh token. |
| `BindToIP` | `bool` | `false` | When `true`, sessions are rejected if the client IP differs from the IP recorded at creation. Useful for high-security environments but breaks users on mobile networks. |
| `BindToDevice` | `bool` | `false` | When `true`, sessions are rejected if the User-Agent differs from the one recorded at creation. |
+| `CleanupInterval` | `time.Duration` | `1h` | How often the sweeper purges expired rows. `0` turns it off |
### ShouldRotateRefreshToken
@@ -128,25 +138,33 @@ The Argon2 defaults (`64 MiB`, `3 iterations`, `2 threads`) meet the OWASP recom
```go
type RateLimitConfig struct {
- SignInLimit int `json:"signin_limit"`
- SignUpLimit int `json:"signup_limit"`
- ForgotPasswordLimit int `json:"forgot_password_limit"`
- MFAChallengeLimit int `json:"mfa_challenge_limit"`
- WindowSeconds int `json:"window_seconds"`
- Enabled bool `json:"enabled"`
+ SignInLimit int `json:"signin_limit"`
+ SignUpLimit int `json:"signup_limit"`
+ RefreshLimit int `json:"refresh_limit"`
+ IntrospectLimit int `json:"introspect_limit"`
+ ForgotPasswordLimit int `json:"forgot_password_limit"`
+ MFAChallengeLimit int `json:"mfa_challenge_limit"`
+ VerifyEmailLimit int `json:"verify_email_limit"`
+ ResendVerificationLimit int `json:"resend_verification_limit"`
+ SSFPushLimit int `json:"ssf_push_limit"`
+ ResetPasswordLimit int `json:"reset_password_limit"`
+ ChangePasswordLimit int `json:"change_password_limit"`
+ OAuthTokenLimit int `json:"oauth_token_limit"`
+ OAuthAuthorizeLimit int `json:"oauth_authorize_limit"`
+ PasskeyLimit int `json:"passkey_limit"`
+ SSOLimit int `json:"sso_limit"`
+ SCIMLimit int `json:"scim_limit"`
+ WaitlistJoinLimit int `json:"waitlist_join_limit"`
+ APIKeyFailureLimit int `json:"api_key_failure_limit"`
+ WindowSeconds int `json:"window_seconds"`
+ Enabled bool `json:"enabled"`
+ FailOpen bool `json:"fail_open"`
}
```
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `Enabled` | `bool` | `false` | Rate limiting is disabled by default. Set to `true` to enable. |
-| `SignInLimit` | `int` | `5` | Max sign-in attempts per IP per window. |
-| `SignUpLimit` | `int` | `3` | Max sign-up attempts per IP per window. |
-| `ForgotPasswordLimit` | `int` | `3` | Max forgot-password requests per IP per window. |
-| `MFAChallengeLimit` | `int` | `5` | Max MFA challenge attempts per session per window. |
-| `WindowSeconds` | `int` | `60` | Sliding window duration in seconds. |
+Rate limiting is on by default (`Enabled: true`). The defaults per window are sign-in 5, sign-up 3, refresh 10, introspect 20, forgot password 3, MFA challenge 5, verify email 10, resend verification 3, SSF push 60, reset password 5, change password 5, OAuth token and authorize 30 each, passkey 10, SSO 20, SCIM 60, waitlist join 5 and failed API-key authentications 20. The window is 60 seconds.
-Rate limiting requires a `ratelimit.Limiter` backend to be configured with `WithRateLimiter`. Without a limiter, rate limit config is ignored.
+When you don't pass `WithRateLimiter`, the engine uses `ratelimit.NewKVLimiter` over the store, so instances that share a database share the counters. `FailOpen` lets requests through when the limiter itself fails; off (the default), a limiter outage answers 503.
## LockoutConfig
@@ -161,12 +179,12 @@ type LockoutConfig struct {
| Field | Type | Default | Description |
|-------|------|---------|-------------|
-| `Enabled` | `bool` | `false` | Account lockout is disabled by default. Set to `true` to enable. |
-| `MaxAttempts` | `int` | `5` | Number of consecutive failed sign-in attempts before the account is locked. |
-| `LockoutDurationSeconds` | `int` | `900` (15 min) | Duration of the lockout in seconds. |
-| `ResetAfterSeconds` | `int` | `3600` (1 hour) | Resets the failure counter after this many seconds of no failures. |
+| `Enabled` | `bool` | `true` | Account lockout |
+| `MaxAttempts` | `int` | `5` | Consecutive failed sign-ins before the account locks |
+| `LockoutDurationSeconds` | `int` | `900` (15 min) | How long the lock lasts |
+| `ResetAfterSeconds` | `int` | `3600` (1 hour) | Clears the failure count after this long without a failure |
-Account lockout requires a `lockout.Tracker` backend configured with `WithLockoutTracker`.
+Without `WithLockoutTracker`, the engine uses `lockout.NewKVTracker` over the store with these settings.
## DefaultConfig
@@ -174,7 +192,7 @@ Account lockout requires a `lockout.Tracker` backend configured with `WithLockou
```go
cfg := authsome.DefaultConfig()
-// cfg.BasePath = "/v1/auth"
+// cfg.BasePath = "/authsome"
// cfg.Session.TokenTTL = 1 * time.Hour
// cfg.Session.RefreshTokenTTL = 30 * 24 * time.Hour
// cfg.Password.MinLength = 12
@@ -182,9 +200,11 @@ cfg := authsome.DefaultConfig()
// cfg.Password.RequireLowercase = true
// cfg.Password.RequireDigit = true
// cfg.Password.BcryptCost = 12
+// cfg.RateLimit.Enabled = true
// cfg.RateLimit.SignInLimit = 5
// cfg.RateLimit.SignUpLimit = 3
// cfg.RateLimit.WindowSeconds = 60
+// cfg.Lockout.Enabled = true
// cfg.Lockout.MaxAttempts = 5
// cfg.Lockout.LockoutDurationSeconds = 900
// cfg.Lockout.ResetAfterSeconds = 3600
@@ -192,22 +212,26 @@ cfg := authsome.DefaultConfig()
## Option functions
-All engine options are `func(*Engine)` type values passed to `authsome.New`.
+Options are `func(*Engine)` values passed to `authsome.NewEngine`.
| Option | Description |
|--------|-------------|
| `WithConfig(cfg Config)` | Set the full config, overriding defaults. |
-| `WithStore(s store.Store)` | Set the persistence backend. Required. |
-| `WithLogger(l *slog.Logger)` | Set the structured logger. |
+| `WithStore(s store.Store)` | Set the persistence backend. Required |
+| `WithLogger(l log.Logger)` | Set the logger (go-utils `log.Logger`) |
| `WithDebug(bool)` | Enable verbose debug logging. |
| `WithAppID(string)` | Set the default application ID. |
| `WithPlugin(p plugin.Plugin)` | Register a plugin. Call multiple times for multiple plugins. |
| `WithStrategy(s, priority int)` | Register a custom auth strategy. |
-| `WithChronicle(bridge.Chronicle)` | Set the audit trail bridge. |
+| `WithChronicle(bridge.Chronicle)` | Set the audit trail bridge. Required |
| `WithMailer(bridge.Mailer)` | Set the transactional email bridge. |
| `WithSMSSender(bridge.SMSSender)` | Set the SMS sending bridge. |
| `WithHerald(bridge.Herald)` | Set the unified notification bridge. |
-| `WithWarden(*warden.Engine)` | Set the Warden authorization engine. |
+| `WithWarden(*warden.Engine)` | Set the Warden authorization engine. Required |
+| `WithAuthorizer(bridge.Authorizer)` | Set the authorizer bridge |
+| `WithKeyManager(bridge.KeyManager)` | Set the key manager bridge |
+| `WithTokenEncryptor(bridge.Encryptor)` | Encrypt stored tokens with your own encryptor instead of the configured keys |
+| `WithLedgerEngine(*ledger.Ledger)` | Set the Ledger engine |
| `WithKeysmith(*keysmith.Engine)` | Set the Keysmith API key engine. |
| `WithEventRelay(bridge.EventRelay)` | Set the webhook event relay. |
| `WithVault(bridge.Vault)` | Set the secrets/config bridge. |
@@ -215,15 +239,22 @@ All engine options are `func(*Engine)` type values passed to `authsome.New`.
| `WithLedger(bridge.Ledger)` | Set the billing bridge. |
| `WithMetrics(bridge.MetricsCollector)` | Set the metrics bridge. |
| `WithBasePath(string)` | Override the URL prefix. |
-| `WithDisableRoutes()` | Prevent automatic route registration. |
+| `WithDisableRoutes()` | Set `DisableRoutes` |
+| `WithDriverName(string)` | Set the Grove driver name for plugin migrations |
+| `WithDB(*grove.DB)` | Give the engine the Grove database |
| `WithDisableMigrate()` | Prevent automatic migration on Start. |
| `WithRateLimiter(ratelimit.Limiter)` | Set the rate limiter backend. |
| `WithLockoutTracker(lockout.Tracker)` | Set the account lockout tracker. |
+| `WithLockoutConfig(LockoutConfig)` | Set the lockout configuration |
+| `WithProtectedResourceMetadataURL(string)` | Set the OAuth protected resource metadata URL |
+| `WithPrincipalAuthTTL(time.Duration)` | Set the principal authentication TTL |
+| `WithDPoPReplayCache(dpop.ReplayCache)` | Set the DPoP replay cache |
| `WithCeremonyStore(ceremony.Store)` | Set the ceremony state store. |
| `WithPasswordHistory(account.PasswordHistoryStore)` | Set the password history store. |
| `WithDefaultTokenFormat(tokenformat.Format)` | Set the default access token format. |
| `WithJWTFormat(appID string, jwt *tokenformat.JWT)` | Set JWT token format for a specific app. |
| `WithAppSessionConfig(*appsessionconfig.Config)` | Seed a per-app session config override. |
+| `WithBootstrap(opts ...BootstrapOption)` | Create the platform app, environments and roles on start |
## Per-app session config overrides
@@ -232,22 +263,27 @@ You can override session configuration for a specific app using `appsessionconfi
```go
import "github.com/xraph/authsome/appsessionconfig"
-eng, err := authsome.New(
- authsome.WithStore(store),
- authsome.WithPlugin(password.New()),
+ttl := 8 * 60 * 60 // 8 hours, in seconds
+refresh := 90 * 24 * 60 * 60 // 90 days
+maxSessions := 20
+
+eng, err := authsome.NewEngine(
+ // plus WithStore, WithChronicle and WithWarden (see Getting Started)
authsome.WithAppSessionConfig(&appsessionconfig.Config{
- AppID: "premium-app",
- TokenTTL: ptr(8 * time.Hour), // longer TTL for premium app
- RefreshTokenTTL: ptr(90 * 24 * time.Hour), // 90 days
- MaxActiveSessions: ptr(20),
+ AppID: premiumAppID, // an id.AppID
+ TokenTTLSeconds: &ttl,
+ RefreshTokenTTLSeconds: &refresh,
+ MaxActiveSessions: &maxSessions,
}),
)
```
+A nil field inherits the global value. `Config` also has `RotateRefreshToken`, `BindToIP`, `BindToDevice`, `TokenExchangeTTLSeconds` and `TokenFormat` (`"opaque"` or `"jwt"`).
+
Per-app session config is seeded into the store during `Start` and takes precedence over the global `SessionConfig`. Per-environment settings take precedence over both.
Resolution order (highest priority wins):
-1. Per-environment settings (`EnvironmentSettings.SessionOverrides`)
+1. Per-environment settings (applied with `environment.Settings.ApplySessionOverrides`)
2. Per-app session config (`appsessionconfig.Config`)
3. Global engine `SessionConfig`
diff --git a/docs/content/docs/concepts/entities.mdx b/docs/content/docs/concepts/entities.mdx
index 5e950afc..2f5eceda 100644
--- a/docs/content/docs/concepts/entities.mdx
+++ b/docs/content/docs/concepts/entities.mdx
@@ -1,6 +1,6 @@
---
title: Entities
-description: Core data types used across Authsome — users, sessions, organizations, devices, and more.
+description: Core data types used across Authsome: users, sessions, organizations, devices, and more.
---
Authsome defines a set of entity types across its packages. Understanding these types helps you work effectively with the engine API, store interfaces, and HTTP responses.
@@ -14,25 +14,27 @@ A `User` represents an authenticated identity within an app.
```go
type User struct {
- ID id.UserID `json:"id"`
- AppID id.AppID `json:"app_id"`
- Email string `json:"email"`
- EmailVerified bool `json:"email_verified"`
- Phone string `json:"phone,omitempty"`
- PhoneVerified bool `json:"phone_verified"`
- Username string `json:"username,omitempty"`
- DisplayUsername string `json:"display_username,omitempty"`
- Name string `json:"name,omitempty"`
- Image string `json:"image,omitempty"`
- PasswordHash string `json:"-"` // never serialized
- PasswordChangedAt *time.Time `json:"password_changed_at,omitempty"`
- Metadata map[string]string `json:"metadata,omitempty"`
- Banned bool `json:"banned"`
- BanReason string `json:"ban_reason,omitempty"`
- BanExpires *time.Time `json:"ban_expires,omitempty"`
- DeletedAt *time.Time `json:"deleted_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.UserID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ EnvID id.EnvironmentID `json:"env_id"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Image string `json:"image,omitempty"`
+ Username string `json:"username,omitempty"`
+ DisplayUsername string `json:"display_username,omitempty"`
+ Phone string `json:"phone,omitempty"`
+ PhoneVerified bool `json:"phone_verified"`
+ PasswordHash string `json:"-"`
+ PasswordChangedAt *time.Time `json:"password_changed_at,omitempty"`
+ Banned bool `json:"banned"`
+ BanReason string `json:"ban_reason,omitempty"`
+ BanExpires *time.Time `json:"ban_expires,omitempty"`
+ Metadata Metadata `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
```
@@ -61,20 +63,36 @@ A `Session` represents an authenticated session for a user.
```go
type Session struct {
- ID id.SessionID `json:"id"`
- UserID id.UserID `json:"user_id"`
- AppID id.AppID `json:"app_id"`
- EnvID id.EnvironmentID `json:"env_id,omitempty"`
- Token string `json:"token"`
- RefreshToken string `json:"refresh_token"`
- ExpiresAt time.Time `json:"expires_at"`
- RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
- IPAddress string `json:"ip_address,omitempty"`
- UserAgent string `json:"user_agent,omitempty"`
- DeviceID id.DeviceID `json:"device_id,omitempty"`
- Actors principal.Chain `json:"actors,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.SessionID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ EnvID id.EnvironmentID `json:"env_id"`
+ UserID id.UserID `json:"user_id"`
+ OrgID id.OrgID `json:"org_id,omitempty"`
+ FamilyID id.SessionFamilyID `json:"family_id,omitempty"`
+ Token string `json:"-"`
+ RefreshToken string `json:"-"`
+ TokenHash string `json:"-"`
+ RefreshTokenHash string `json:"-"`
+ IPAddress string `json:"ip_address,omitempty"`
+ UserAgent string `json:"user_agent,omitempty"`
+ DeviceID id.DeviceID `json:"device_id,omitempty"`
+ LastActivityAt time.Time `json:"last_activity_at,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Roles []string `json:"roles,omitempty"`
+ Audience []string `json:"aud,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ PrincipalKind principal.Kind `json:"principal_kind,omitempty"`
+ ServiceAccountID id.ServiceAccountID `json:"service_account_id,omitempty"`
+ AgentID id.AgentID `json:"agent_id,omitempty"`
+ GrantID id.AgentGrantID `json:"grant_id,omitempty"`
+ Actors principal.Chain `json:"actors,omitempty"`
+ ActorGrant principal.GrantKind `json:"actor_grant,omitempty"`
+ DelegationID id.DelegationID `json:"delegation_id,omitempty"`
+ DPoPJKT string `json:"dpop_jkt,omitempty"`
+ ClientID string `json:"client_id,omitempty"`
}
```
@@ -99,15 +117,15 @@ An `App` represents an application registered in Authsome. All entities are scop
```go
type App struct {
- ID id.AppID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description,omitempty"`
- Logo string `json:"logo,omitempty"`
- Settings *AppSettings `json:"settings,omitempty"`
- Active bool `json:"active"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.AppID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Logo string `json:"logo,omitempty"`
+ PublishableKey string `json:"publishable_key,omitempty"`
+ IsPlatform bool `json:"is_platform"`
+ Metadata Metadata `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
@@ -118,20 +136,20 @@ type App struct {
**Package:** `github.com/xraph/authsome/organization`
**ID prefix:** `aorg_`
-An `Organization` is a multi-user tenant within an app. Members belong to organizations and can have organization-scoped roles.
+An `Organization` is a group of users within an app, provided by the `organization` plugin. Members have an `owner`, `admin` or `member` role in it.
```go
type Organization struct {
- ID id.OrganizationID `json:"id"`
- AppID id.AppID `json:"app_id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Logo string `json:"logo,omitempty"`
- Description string `json:"description,omitempty"`
- Metadata map[string]string `json:"metadata,omitempty"`
- Active bool `json:"active"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.OrgID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ EnvID id.EnvironmentID `json:"env_id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Logo string `json:"logo,omitempty"`
+ Metadata Metadata `json:"metadata,omitempty"`
+ CreatedBy id.UserID `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
@@ -141,14 +159,12 @@ type Organization struct {
```go
type Member struct {
- ID id.MemberID `json:"id"`
- OrganizationID id.OrganizationID `json:"organization_id"`
- UserID id.UserID `json:"user_id"`
- AppID id.AppID `json:"app_id"`
- Role string `json:"role"` // "owner", "admin", "member"
- JoinedAt time.Time `json:"joined_at"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.MemberID `json:"id"`
+ OrgID id.OrgID `json:"org_id"`
+ UserID id.UserID `json:"user_id"`
+ Role MemberRole `json:"role"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
@@ -158,16 +174,16 @@ type Member struct {
```go
type Invitation struct {
- ID id.InvitationID `json:"id"`
- OrganizationID id.OrganizationID `json:"organization_id"`
- AppID id.AppID `json:"app_id"`
- Email string `json:"email"`
- Role string `json:"role"`
- Token string `json:"-"` // sent via email
- InvitedBy id.UserID `json:"invited_by"`
- ExpiresAt time.Time `json:"expires_at"`
- AcceptedAt *time.Time `json:"accepted_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID id.InvitationID `json:"id"`
+ OrgID id.OrgID `json:"org_id"`
+ Email string `json:"email"`
+ Role MemberRole `json:"role"`
+ InviterID id.UserID `json:"inviter_id"`
+ Status InvitationStatus `json:"status"`
+ Token string `json:"-"`
+ TokenHash string `json:"-"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
}
```
@@ -182,18 +198,21 @@ A `Device` represents a browser, mobile app, or other client that has authentica
```go
type Device struct {
- ID id.DeviceID `json:"id"`
- UserID id.UserID `json:"user_id"`
- AppID id.AppID `json:"app_id"`
- Fingerprint string `json:"fingerprint"`
- Name string `json:"name,omitempty"` // "Chrome on macOS"
- Browser string `json:"browser,omitempty"`
- OS string `json:"os,omitempty"`
- IPAddress string `json:"ip_address,omitempty"`
- Trusted bool `json:"trusted"`
- LastSeenAt time.Time `json:"last_seen_at"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.DeviceID `json:"id"`
+ UserID id.UserID `json:"user_id"`
+ AppID id.AppID `json:"app_id"`
+ EnvID id.EnvironmentID `json:"env_id"`
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ Browser string `json:"browser,omitempty"`
+ OS string `json:"os,omitempty"`
+ IPAddress string `json:"ip_address,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ Trusted bool `json:"trusted"`
+ TrustedUntil *time.Time `json:"trusted_until,omitempty"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
@@ -214,15 +233,17 @@ A `Webhook` is a registered endpoint that receives event notifications.
```go
type Webhook struct {
- ID id.WebhookID `json:"id"`
- AppID id.AppID `json:"app_id"`
- EnvID id.EnvironmentID `json:"env_id,omitempty"`
- URL string `json:"url"`
- Events []string `json:"events"` // ["user.created", "auth.signin"]
- Secret string `json:"-"` // HMAC-SHA256 signing secret
- Active bool `json:"active"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.WebhookID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ EnvID id.EnvironmentID `json:"env_id"`
+ URL string `json:"url"`
+ Events []string `json:"events"`
+ Secret string `json:"-"`
+ SecretHash string `json:"-"`
+ RelayEndpointID string `json:"relay_endpoint_id,omitempty"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
@@ -243,10 +264,13 @@ type Environment struct {
AppID id.AppID `json:"app_id"`
Name string `json:"name"`
Slug string `json:"slug"`
- Type EnvironmentType `json:"type"` // "production", "staging", "development"
- Color string `json:"color"` // UI hint: "#22c55e"
+ Type Type `json:"type"`
IsDefault bool `json:"is_default"`
- Settings *EnvironmentSettings `json:"settings,omitempty"`
+ Color string `json:"color,omitempty"`
+ Description string `json:"description,omitempty"`
+ Settings *Settings `json:"settings,omitempty"`
+ ClonedFrom id.EnvironmentID `json:"cloned_from,omitempty"`
+ Metadata Metadata `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -263,34 +287,34 @@ type Environment struct {
```go
type Role struct {
- ID string `json:"id"` // arol_...
- AppID string `json:"app_id"`
- EnvID string `json:"env_id,omitempty"`
- ParentID string `json:"parent_id,omitempty"` // role hierarchy
- Name string `json:"name"`
- Slug string `json:"slug"` // unique within app
- Description string `json:"description,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ AppID string `json:"app_id"`
+ EnvID string `json:"env_id"`
+ ParentID string `json:"parent_id,omitempty"` // Empty = root role (no parent)
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ NamespacePath string `json:"-"`
}
type Permission struct {
- ID string `json:"id"` // aprm_...
+ ID string `json:"id"`
RoleID string `json:"role_id"`
- Action string `json:"action"` // e.g. "delete"
- Resource string `json:"resource"` // e.g. "users"
+ Action string `json:"action"` // e.g. "read", "write", "delete", "admin"
+ Resource string `json:"resource"` // e.g. "user", "org", "document"
}
type UserRole struct {
UserID string `json:"user_id"`
RoleID string `json:"role_id"`
- AppID string `json:"app_id"`
+ OrgID string `json:"org_id,omitempty"` // empty = global scope
AssignedAt time.Time `json:"assigned_at"`
- AssignedBy string `json:"assigned_by,omitempty"`
}
```
-Roles form a hierarchy via `ParentID`. Permissions from parent roles are inherited. The engine evaluates the full hierarchy when checking `HasPermission`.
+Roles and permissions are stored in Warden, which evaluates them for `HasPermission`. `UserRole.OrgID` isn't applied yet; see [Org-Scoped Roles](/docs/organizations/org-scoped-roles).
---
@@ -303,23 +327,26 @@ A `FormConfig` defines custom fields added to the sign-up form (or other flows).
```go
type FormConfig struct {
- ID id.FormConfigID `json:"id"`
- AppID id.AppID `json:"app_id"`
- FormType FormType `json:"form_type"` // "signup", "signin", "profile"
- Fields []Field `json:"fields"`
- Active bool `json:"active"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.FormConfigID `json:"id"`
+ AppID id.AppID `json:"app_id"`
+ FormType string `json:"form_type"` // "signup", future: "profile_edit"
+ Fields []FormField `json:"fields"`
+ Active bool `json:"active"`
+ Version int `json:"version"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
-type Field struct {
- Name string `json:"name"` // metadata key
- Label string `json:"label"` // UI label
- Type FieldType `json:"type"` // "text", "email", "select", "checkbox"
- Required bool `json:"required"`
- Placeholder string `json:"placeholder,omitempty"`
- Options []string `json:"options,omitempty"` // for select fields
- Validation *Validation `json:"validation,omitempty"`
+type FormField struct {
+ Key string `json:"key"` // stored as key in User.Metadata
+ Label string `json:"label"`
+ Type FieldType `json:"type"`
+ Placeholder string `json:"placeholder,omitempty"`
+ Description string `json:"description,omitempty"`
+ Options []SelectOption `json:"options,omitempty"` // for select/radio/checkbox
+ Default string `json:"default,omitempty"`
+ Validation Validation `json:"validation,omitempty"`
+ Order int `json:"order"`
}
```
diff --git a/docs/content/docs/concepts/errors.mdx b/docs/content/docs/concepts/errors.mdx
index 0e0d2e38..fbe32ee9 100644
--- a/docs/content/docs/concepts/errors.mdx
+++ b/docs/content/docs/concepts/errors.mdx
@@ -3,7 +3,7 @@ title: Errors
description: Sentinel error values, API error response format, and error handling patterns.
---
-Authsome defines sentinel error values in dedicated packages. Use `errors.Is` to check for specific error conditions. Never compare error strings directly — error messages are not part of the public API.
+Authsome defines sentinel error values in dedicated packages. Use `errors.Is` to check for specific error conditions. Never compare error strings directly: error messages are not part of the public API.
## Using errors.Is
@@ -18,13 +18,13 @@ import (
u, sess, err := engine.SignIn(ctx, req)
switch {
case errors.Is(err, account.ErrInvalidCredentials):
- // Wrong email or password — return 401
+ // Wrong email or password: return 401
case errors.Is(err, account.ErrAccountLocked):
- // Too many failed attempts — return 423
+ // Too many failed attempts: return 423
case errors.Is(err, account.ErrUserBanned):
- // User is banned — return 403
+ // User is banned: return 403
case err != nil:
- // Unexpected error — return 500
+ // Unexpected error: return 500
default:
// Success
}
@@ -39,11 +39,10 @@ All service methods wrap sentinel errors using `fmt.Errorf` and `%w`, so `errors
| Error | Description |
|-------|-------------|
| `store.ErrNotFound` | The requested entity does not exist. Returned by all Get/Lookup operations. |
-| `store.ErrDuplicate` | A unique constraint was violated (e.g., duplicate email). |
-| `store.ErrConflict` | A write conflict or optimistic lock violation occurred. |
+| `store.ErrConflict` | A write hit a uniqueness constraint that has no more specific error (duplicate emails come back as `account.ErrEmailTaken`). |
```go
-u, err := engine.Store().GetUserByEmail(ctx, appID, email)
+u, err := eng.GetUserByEmail(ctx, appID, email)
if errors.Is(err, store.ErrNotFound) {
// User does not exist
}
@@ -66,10 +65,10 @@ if errors.Is(err, store.ErrNotFound) {
| Error | Description |
|-------|-------------|
-| `account.ErrPasswordBreached` | The password was found in the HaveIBeenPwned breach database. Only returned when `CheckBreached: true`. |
+| `account.ErrWeakPassword` | The password doesn't meet the policy (length, uppercase, digit, special). |
+| `account.ErrPasswordBreached` | The password was found in the HaveIBeenPwned breach database. Only returned when `CheckBreached: true`. It wraps `ErrWeakPassword`. |
| `account.ErrPasswordReused` | The new password matches one of the user's previous passwords. Only returned when `HistoryCount > 0`. |
-| `account.ErrPasswordExpired` | The user's password has exceeded `MaxAgeDays`. Returned from `SignIn` — the user must change their password. |
-| `account.ErrPasswordTooWeak` | The password does not meet the policy (min length, uppercase, digit, special). |
+| `account.ErrPasswordExpired` | The user's password has exceeded `MaxAgeDays`. Returned from `SignIn`: the user must change their password. |
### Registration errors
@@ -80,41 +79,37 @@ if errors.Is(err, store.ErrNotFound) {
## API error response format
-When a request fails, the HTTP API returns a JSON error body with a consistent shape:
+A failed request returns a JSON body with the status in `code` and a message in `error`. Errors that clients need to tell apart also carry a `type`:
```json
-{
- "error": {
- "code": "invalid_credentials",
- "message": "Invalid email or password.",
- "status": 401
- }
-}
+{"code": 403, "error": "please verify your email address before signing in", "type": "email_not_verified"}
```
### HTTP status code mapping
-| Sentinel error | HTTP status | Error code |
-|----------------|-------------|------------|
-| `account.ErrInvalidCredentials` | `401 Unauthorized` | `invalid_credentials` |
-| `account.ErrSessionExpired` | `401 Unauthorized` | `session_expired` |
-| `account.ErrUserBanned` | `403 Forbidden` | `user_banned` |
-| `account.ErrAccountLocked` | `423 Locked` | `account_locked` |
-| `account.ErrEmailTaken` | `409 Conflict` | `email_taken` |
-| `account.ErrUsernameTaken` | `409 Conflict` | `username_taken` |
-| `account.ErrPasswordBreached` | `422 Unprocessable Entity` | `password_breached` |
-| `account.ErrPasswordReused` | `422 Unprocessable Entity` | `password_reused` |
-| `account.ErrPasswordExpired` | `428 Precondition Required` | `password_expired` |
-| `account.ErrPasswordTooWeak` | `422 Unprocessable Entity` | `password_too_weak` |
-| `store.ErrNotFound` | `404 Not Found` | `not_found` |
-| Any unexpected error | `500 Internal Server Error` | `internal_error` |
+| Error | HTTP status | `type` |
+|-------|-------------|--------|
+| `store.ErrNotFound` | `404 Not Found` | |
+| `account.ErrInvalidCredentials` | `401 Unauthorized` | |
+| `account.ErrSessionExpired` | `401 Unauthorized` | |
+| `account.ErrEmailTaken`, `account.ErrUsernameTaken`, `store.ErrConflict` | `409 Conflict` | |
+| `account.ErrMFARequired` | `403 Forbidden` | `mfa_required`, with `mfa_ticket` and `available_methods` |
+| `account.ErrEmailNotVerified` | `403 Forbidden` | `email_not_verified` |
+| `account.ErrUserBanned` | `403 Forbidden` | |
+| `account.ErrAccountLocked` | `423 Locked` | `account_locked`, with `retry_after` |
+| `account.ErrRateLimited` | `429 Too Many Requests` | `rate_limited` |
+| `account.ErrTooManyAttempts` | `429 Too Many Requests` | |
+| `account.ErrWeakPassword` (and `ErrPasswordBreached`) | `400 Bad Request` | |
+| Anything else | `500 Internal Server Error` | |
+
+`account.ErrPasswordReused` and `account.ErrPasswordExpired` have no mapping of their own, so they come back as 500 over HTTP. Check for them with `errors.Is` when you call the engine from Go.
## Error wrapping patterns
Authsome wraps all internal errors with context before returning them. The wrapping format is consistent: `"authsome: : "`.
```go
-// Internal wrapping — always use errors.Is, not string comparison.
+// Internal wrapping: always use errors.Is, not string comparison.
if err := e.store.CreateUser(ctx, u); err != nil {
return nil, nil, fmt.Errorf("authsome: create user: %w", err)
}
@@ -137,7 +132,7 @@ if err != nil {
## Plugin errors
-Plugin `OnBeforeSignUp` and `OnBeforeSignIn` hooks can return custom errors to block the operation. Return any error value — it will be wrapped and propagated to the caller:
+Plugin `OnBeforeSignUp` and `OnBeforeSignIn` hooks can return custom errors to block the operation. Return any error value: it will be wrapped and propagated to the caller:
```go
func (p *DomainRestrictPlugin) OnBeforeSignUp(_ context.Context, req *account.SignUpRequest) error {
diff --git a/docs/content/docs/concepts/identity.mdx b/docs/content/docs/concepts/identity.mdx
index 3452e100..c47b172a 100644
--- a/docs/content/docs/concepts/identity.mdx
+++ b/docs/content/docs/concepts/identity.mdx
@@ -15,7 +15,7 @@ The `ausr` prefix identifies this as a user. The suffix is a base32-encoded UUID
## The `id` package
-The `id` package in `github.com/xraph/authsome/id` provides the `ID` struct and type-specific named types built on top of it. All entity types share the same underlying struct — the prefix distinguishes them at parse time.
+The `id` package in `github.com/xraph/authsome/id` provides the `ID` struct and type-specific named types built on top of it. All entity types share the same underlying struct: the prefix distinguishes them at parse time.
### Creating IDs
@@ -26,7 +26,7 @@ import "github.com/xraph/authsome/id"
userID := id.NewUserID() // ausr_01j9...
sessionID := id.NewSessionID() // ases_01j9...
appID := id.NewAppID() // aapp_01j9...
-orgID := id.NewOrganizationID() // aorg_01j9...
+orgID := id.NewOrgID() // aorg_01j9...
memberID := id.NewMemberID() // amem_01j9...
teamID := id.NewTeamID() // atm_01j9...
inviteID := id.NewInvitationID() // ainv_01j9...
@@ -49,7 +49,7 @@ sessID, err := id.ParseSessionID("ases_01j9k3mq2fhex4vsk0n852p0qt")
appID, err := id.ParseAppID("aapp_01j9k3mq2fhex4vsk0n852p0qt")
```
-Parsing with a prefix-specific function validates that the prefix matches. Passing a `ausr_` ID to `ParseSessionID` returns an error — this prevents cross-entity ID confusion at the API boundary.
+Parsing with a prefix-specific function validates that the prefix matches. Passing a `ausr_` ID to `ParseSessionID` returns an error: this prevents cross-entity ID confusion at the API boundary.
### Nil ID
@@ -71,7 +71,7 @@ fmt.Println(userID.Prefix()) // "ausr"
### Database serialization
-All ID types implement `sql.Scanner` and `driver.Valuer`. They store as plain strings in the database, and return `NULL` for nil IDs. No special column type is needed — use `VARCHAR` or `TEXT`.
+All ID types implement `sql.Scanner` and `driver.Valuer`. They store as plain strings in the database, and return `NULL` for nil IDs. No special column type is needed: use `VARCHAR` or `TEXT`.
### JSON serialization
@@ -79,47 +79,68 @@ All ID types implement `encoding.TextMarshaler` and `encoding.TextUnmarshaler`.
## Prefix reference
-All 27 Authsome TypeID prefixes:
+The prefixes defined in `id/id.go`:
| Constant | Prefix | Entity |
|----------|--------|--------|
| `id.PrefixUser` | `ausr` | User account |
-| `id.PrefixSession` | `ases` | Auth session |
+| `id.PrefixSession` | `ases` | Session |
| `id.PrefixApp` | `aapp` | Application |
-| `id.PrefixOrganization` | `aorg` | Organization |
+| `id.PrefixOrg` | `aorg` | Organization |
| `id.PrefixMember` | `amem` | Organization member |
| `id.PrefixTeam` | `atm` | Team |
-| `id.PrefixInvitation` | `ainv` | Org invitation |
+| `id.PrefixInvitation` | `ainv` | Organization invitation |
| `id.PrefixDevice` | `adev` | Device |
| `id.PrefixWebhook` | `awhk` | Webhook endpoint |
-| `id.PrefixNotification` | `antf` | Notification record |
-| `id.PrefixVerification` | `avrf` | Email/phone verification token |
+| `id.PrefixNotification` | `antf` | Notification |
+| `id.PrefixVerification` | `avrf` | Email or phone verification token |
| `id.PrefixPasswordReset` | `apwr` | Password reset token |
| `id.PrefixAPIKey` | `akey` | API key |
-| `id.PrefixOAuthAccount` | `aoau` | Linked OAuth account |
+| `id.PrefixOAuthConnection` | `aoau` | Linked social account |
| `id.PrefixPasskey` | `apsk` | Passkey credential |
-| `id.PrefixMFADevice` | `amfa` | MFA device (TOTP/SMS) |
+| `id.PrefixMFA` | `amfa` | MFA enrollment |
| `id.PrefixEnvironment` | `aenv` | Environment |
| `id.PrefixRole` | `arol` | RBAC role |
| `id.PrefixPermission` | `aprm` | RBAC permission |
| `id.PrefixRecoveryCode` | `arec` | MFA recovery code |
| `id.PrefixSSOConnection` | `asso` | SSO connection |
-| `id.PrefixConsent` | `acns` | OAuth2 consent record |
-| `id.PrefixFormConfig` | `afcf` | Dynamic form configuration |
-| `id.PrefixBridge` | `abrd` | Bridge configuration |
-| `id.PrefixAppSessionConfig` | `ascf` | Per-app session config |
-| `id.PrefixOAuth2Client` | `ao2c` | OAuth2 provider client |
-| `id.PrefixAuditACO` | `aaco` | Audit access control object |
+| `id.PrefixConsent` | `acns` | Consent record |
+| `id.PrefixFormConfig` | `afcf` | Form configuration |
+| `id.PrefixBrandingConfig` | `abrd` | Branding configuration |
+| `id.PrefixAppSessionCfg` | `ascf` | Per-app session config |
+| `id.PrefixOAuth2Client` | `aoac` | OAuth2 provider client |
+| `id.PrefixOAuth2Grant` | `aoag` | OAuth2 grant |
+| `id.PrefixAuthCode` | `aaco` | OAuth2 authorization code |
+| `id.PrefixSetting` | `aset` | Setting |
+| `id.PrefixAppClientConfig` | `aacf` | Per-app client config |
+| `id.PrefixDeviceCode` | `advc` | OAuth2 device code |
+| `id.PrefixSCIMConfig` | `ascm` | SCIM config |
+| `id.PrefixSCIMToken` | `asct` | SCIM token |
+| `id.PrefixSCIMLog` | `ascl` | SCIM provisioning log |
+| `id.PrefixWaitlist` | `awlt` | Waitlist entry |
+| `id.PrefixSessionFamily` | `asfm` | Session family (refresh rotation) |
+| `id.PrefixServiceAccount` | `svc` | Service account |
+| `id.PrefixUserEmail` | `auem` | User email address |
+| `id.PrefixDelegation` | `adel` | Delegation |
+| `id.PrefixSSFStream` | `assf` | Shared Signals stream |
+| `id.PrefixSSFLink` | `assl` | Shared Signals subject link |
+| `id.PrefixSSFEvent` | `asse` | Shared Signals event |
+| `id.PrefixSSFSignal` | `assg` | Shared Signals signal |
+| `id.PrefixAgent` | `aagt` | Agent |
+| `id.PrefixAgentGrant` | `aagr` | Agent grant |
+| `id.PrefixRetentionJob` | `artj` | Retention job |
+| `id.PrefixRetentionRef` | `artr` | Retention contact reference |
+
## Named ID types
-Each entity has a named type alias over the base `id.ID` for additional compile-time safety:
+Each entity has a named alias of the base `id.ID`. They're type aliases (`= ID`), so they document intent but the compiler won't stop you passing one where another is expected; the prefix check happens when you parse.
```go
type UserID = ID
type SessionID = ID
type AppID = ID
-type OrganizationID = ID
+type OrgID = ID
type MemberID = ID
type TeamID = ID
type InvitationID = ID
@@ -128,12 +149,13 @@ type WebhookID = ID
type EnvironmentID = ID
type RoleID = ID
type PermissionID = ID
-type MFADeviceID = ID
+type MFAID = ID
type PasskeyID = ID
type APIKeyID = ID
+// ...and one for every other prefix
```
-Using named types in function signatures makes intent explicit and prevents accidental misuse:
+Using them in function signatures makes intent explicit:
```go
// Clear: the function wants a UserID, not just any ID.
diff --git a/docs/content/docs/concepts/multi-tenancy.mdx b/docs/content/docs/concepts/multi-tenancy.mdx
index 2774852a..b0a757a7 100644
--- a/docs/content/docs/concepts/multi-tenancy.mdx
+++ b/docs/content/docs/concepts/multi-tenancy.mdx
@@ -7,12 +7,12 @@ Authsome supports multi-tenancy at multiple levels. Understanding the isolation
## App-scoped isolation
-The primary isolation unit in Authsome is the **App**. Every entity — users, sessions, organizations, webhooks, roles, form configs — is scoped to an `AppID`. The store layer enforces this: every query includes the `AppID` as a mandatory filter.
+The primary isolation unit in Authsome is the **App**. Every entity (users, sessions, organizations, webhooks, roles, form configs) is scoped to an `AppID`. Store lookups take the `AppID` (and for users, the environment) as arguments.
```go
// Users from app-1 are completely isolated from users in app-2.
-u1, _ := engine.Store().GetUserByEmail(ctx, appID1, "alice@example.com")
-u2, _ := engine.Store().GetUserByEmail(ctx, appID2, "alice@example.com")
+u1, _ := eng.GetUserByEmail(ctx, appID1, "alice@example.com")
+u2, _ := eng.GetUserByEmail(ctx, appID2, "alice@example.com")
// u1 and u2 are independent records. Same email, different apps.
```
@@ -22,9 +22,9 @@ Cross-app data access is structurally impossible at the store level. There is no
Depending on your product design, an App might be:
-- **A single product** — one Authsome App per SaaS product
-- **A per-customer isolated environment** — one App per customer in a white-label scenario
-- **A multi-environment setup** — one App with multiple Environments (production, staging, development)
+- A single product: one Authsome App per SaaS product
+- A per-customer isolated environment: one App per customer in a white-label scenario
+- A multi-environment setup: one App with multiple Environments (production, staging, development)
Most products use a small number of Apps (often one) and use **Organizations** within that App to represent customer tenants.
@@ -34,22 +34,20 @@ Organizations provide a second level of isolation within a single App. Users bel
```go
// User alice is a member of two organizations within the same app.
-orgs, _ := engine.ListUserOrganizations(ctx, alice.ID)
-// orgs = [{"id": "aorg_acme...", "name": "Acme Corp"}, {"id": "aorg_globex...", "name": "Globex"}]
+// orgs is the organization plugin (*organization.Plugin).
+userOrgs, _ := orgs.ListUserOrganizations(ctx, alice.ID)
+// userOrgs = [{"id": "aorg_acme...", "name": "Acme Corp"}, {"id": "aorg_globex...", "name": "Globex"}]
```
Organization-scoped operations act on members and resources within that organization:
```go
// List only the users who are members of Acme Corp.
-members, _ := engine.ListOrganizationMembers(ctx, acmeOrgID)
+members, _ := orgs.ListMembers(ctx, acmeOrgID)
-// Assign a role scoped to the organization.
-engine.AssignUserRole(ctx, appID, &rbac.UserRole{
- UserID: alice.ID.String(),
- RoleID: adminRoleID.String(),
- OrgID: acmeOrgID.String(), // scoped to Acme only
-})
+// Each member has an owner, admin or member role inside the organization.
+// App RBAC roles are app-wide: UserRole.OrgID isn't applied yet
+// (see Org-Scoped Roles).
```
### B2B SaaS pattern
@@ -74,7 +72,7 @@ Users can sign up to the app independently and then create or be invited into or
Environments provide a third level of isolation for configuration and secrets. Production, staging, and development environments within the same App are fully isolated from each other.
```go
-envs, _ := engine.ListEnvironments(ctx, appID)
+envs, _ := eng.ListEnvironments(ctx, appID)
// [
// {"id": "aenv_prod...", "slug": "production", "is_default": true},
// {"id": "aenv_stag...", "slug": "staging"},
@@ -86,20 +84,18 @@ envs, _ := engine.ListEnvironments(ctx, appID)
| Resource | Isolated per environment |
|----------|--------------------------|
-| Session config (TTL, rotation) | Yes — via `EnvironmentSettings` |
-| Webhook endpoints | Yes — webhooks are scoped to an `EnvID` |
-| RBAC roles and permissions | Yes — roles carry an `EnvID` |
-| User data | No — users are shared across environments within an app |
+| Users, sessions, organizations | Yes. Each carries an `env_id`, and the same email can have a separate account per environment |
+| Session config, password policy, rate limits, lockout | Yes, through the environment's `Settings` overrides |
+| Webhook endpoints | Yes. Webhooks carry an `EnvID` |
+| RBAC roles and permissions | No. They're stored in Warden with the app as the tenant |
Sessions carry an `EnvID` which determines which environment-level session config applies:
```go
// Short-lived tokens in development, long-lived in production.
-devEnvSettings := &environment.EnvironmentSettings{
- SessionOverrides: &SessionOverrides{
- TokenTTL: ptr(15 * time.Minute),
- },
-}
+ttl := 15 * 60 // seconds
+devEnv.Settings = &environment.Settings{TokenTTLSeconds: &ttl}
+err := eng.UpdateEnvironment(ctx, devEnv)
```
### Cloning environments
@@ -107,8 +103,7 @@ devEnvSettings := &environment.EnvironmentSettings{
Clone an environment's configuration (roles, permissions, webhooks) to bootstrap a new environment:
```go
-result, err := engine.CloneEnvironment(ctx, environment.CloneRequest{
- AppID: appID,
+result, err := eng.CloneEnvironment(ctx, environment.CloneRequest{
SourceEnvID: productionEnvID,
Name: "Staging",
Slug: "staging",
@@ -117,16 +112,16 @@ result, err := engine.CloneEnvironment(ctx, environment.CloneRequest{
// result.RolesCloned, result.PermissionsCloned, result.WebhooksCloned
```
-User data is never cloned — only structural configuration is copied.
+User data is never cloned: only structural configuration is copied.
## Cross-app guarantees
The following properties hold regardless of which store backend is in use:
-1. **No cross-app user lookup** — `GetUserByEmail(ctx, appID1, email)` never returns a user from `appID2`, even if both have a user with the same email.
-2. **No cross-app session validation** — A session token from App 1 cannot authenticate a request scoped to App 2.
-3. **No cross-app role inheritance** — Roles from App 1 have no effect on permission checks in App 2.
-4. **Webhook isolation** — Webhooks registered for App 1 do not fire for events in App 2.
+1. No cross-app user lookup: `GetUserByEmail(ctx, appID1, email)` never returns a user from `appID2`, even if both have a user with the same email.
+2. No cross-app session validation: A session token from App 1 cannot authenticate a request scoped to App 2.
+3. No cross-app role inheritance: Roles from App 1 have no effect on permission checks in App 2.
+4. Webhook isolation: Webhooks registered for App 1 do not fire for events in App 2.
## Scoping requests in middleware
@@ -135,16 +130,22 @@ When using the Authsome HTTP middleware, the authenticated user's `AppID` is ext
```go
import "github.com/xraph/authsome/middleware"
-// middleware.RequireAuth extracts the Bearer token, validates it, and
-// injects the user and session into the request context.
-mux.Handle("/api/", middleware.RequireAuth(engine)(func(w http.ResponseWriter, r *http.Request) {
- user := middleware.UserFromContext(r.Context())
- sess := middleware.SessionFromContext(r.Context())
+// The auth middleware (installed on the router, or by the Forge extension)
+// puts the user and session on the request context. RequireAuth rejects
+// requests without them.
+router.GET("/api/items", func(ctx forge.Context) error {
+ u, _ := middleware.UserFrom(ctx.Context())
+ sess, _ := middleware.SessionFrom(ctx.Context())
+ _ = sess
- // user.AppID tells you which app this request belongs to.
+ // u.AppID tells you which app this request belongs to.
// Use it to scope any downstream data access.
- items, err := myStore.ListItems(r.Context(), user.AppID)
-}))
+ items, err := myStore.ListItems(ctx.Context(), u.AppID)
+ if err != nil {
+ return err
+ }
+ return ctx.JSON(http.StatusOK, items)
+}, forge.WithMiddleware(middleware.RequireAuth()))
```
## Identifying the requesting organization
@@ -153,14 +154,14 @@ For B2B applications where API requests are scoped to an organization, pass the
```go
orgIDStr := r.Header.Get("X-Organization-ID")
-orgID, err := id.ParseOrganizationID(orgIDStr)
+orgID, err := id.ParseOrgID(orgIDStr)
if err != nil {
http.Error(w, "invalid organization ID", http.StatusBadRequest)
return
}
// Verify the authenticated user is a member of this organization.
-member, err := engine.GetOrganizationMember(r.Context(), orgID, user.ID)
+member, err := eng.Store().GetMemberByUserAndOrg(r.Context(), user.ID, orgID)
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "not a member", http.StatusForbidden)
return
diff --git a/docs/content/docs/dynamic-forms/branding.mdx b/docs/content/docs/dynamic-forms/branding.mdx
index d4a4cf5e..5b21bab8 100644
--- a/docs/content/docs/dynamic-forms/branding.mdx
+++ b/docs/content/docs/dynamic-forms/branding.mdx
@@ -3,6 +3,10 @@ title: Branding
description: Customize auth page appearance with logos, colors, fonts, and CSS overrides per organization.
---
+
+The HTTP routes this page shows don't exist. Neither the standalone API nor the Forge extension mounts form or branding routes. Manage them from Go with the engine methods (`GetSignupFormConfig`, `SaveSignupFormConfig`, `DeleteSignupFormConfig`, `ListFormConfigs`, `GetOrgBranding`, `SaveOrgBranding`, `DeleteOrgBranding`) or through the Forge dashboard, which edits them over its contract. Clients get the app's branding from `GET /v1/client-config`.
+
+
The branding system allows you to customize the visual appearance of Authsome's authentication pages on a per-organization basis. Each organization can define its own logo, color scheme, fonts, and custom CSS, creating a white-labeled experience for their users.
## BrandingConfig entity
@@ -25,8 +29,8 @@ type BrandingConfig struct {
}
```
-- **`OrgID`** — the organization this branding applies to. Each organization can have one branding config per app.
-- **`AppID`** — the application this branding is scoped to.
+- `OrgID`: the organization this branding applies to. Each organization can have one branding config per app.
+- `AppID`: the application this branding is scoped to.
- All color fields accept standard CSS color values (hex, rgb, hsl).
- Branding config IDs have the prefix `abrd_`.
@@ -47,7 +51,7 @@ type BrandingConfig struct {
### Via HTTP API
-`POST /v1/auth/branding`
+`POST /v1/branding`
```json
{
@@ -177,7 +181,7 @@ globexBranding := &formconfig.BrandingConfig{
## Updating branding
-`PATCH /v1/auth/branding/:brandingId`
+`PATCH /v1/branding/:brandingId`
```json
{
@@ -192,13 +196,13 @@ Only fields included in the request are updated. Other fields remain unchanged.
### By organization
-`GET /v1/auth/branding?org_id=aorg_01jb...&app_id=myapp`
+`GET /v1/branding?org_id=aorg_01jb...&app_id=myapp`
Returns the branding config for the specified organization and app.
### Active branding for auth page
-`GET /v1/auth/branding/resolve?org_slug=acme&app_id=myapp`
+`GET /v1/branding/resolve?org_slug=acme&app_id=myapp`
Used by the Authsome UI to resolve branding when rendering auth pages.
diff --git a/docs/content/docs/dynamic-forms/custom-schemas.mdx b/docs/content/docs/dynamic-forms/custom-schemas.mdx
index 73ea07a1..bb6df5d5 100644
--- a/docs/content/docs/dynamic-forms/custom-schemas.mdx
+++ b/docs/content/docs/dynamic-forms/custom-schemas.mdx
@@ -3,6 +3,10 @@ title: Custom Form Schemas
description: Define dynamic signup forms with custom fields, validation rules, and per-app configuration using FormConfig.
---
+
+The HTTP routes this page shows don't exist. Neither the standalone API nor the Forge extension mounts form or branding routes. Manage them from Go with the engine methods (`GetSignupFormConfig`, `SaveSignupFormConfig`, `DeleteSignupFormConfig`, `ListFormConfigs`, `GetOrgBranding`, `SaveOrgBranding`, `DeleteOrgBranding`) or through the Forge dashboard, which edits them over its contract. Clients get the app's branding from `GET /v1/client-config`.
+
+
Authsome supports dynamic form schemas that let you customize the signup experience per application. Instead of hardcoding form fields, you define a `FormConfig` that specifies which fields appear, their types, validation rules, and display order. Custom field values are stored in the user's `Metadata` map.
## FormConfig entity
@@ -20,9 +24,9 @@ type FormConfig struct {
}
```
-- **`FormType`** — currently `"signup"` is supported. Future versions will add `"profile_edit"` and others.
-- **`Active`** — only one FormConfig per app/form-type combination can be active at a time. Creating a new active config deactivates the previous one.
-- **`Version`** — auto-incremented on each update, enabling form versioning.
+- `FormType`: currently `"signup"` is supported. Future versions will add `"profile_edit"` and others.
+- `Active`: only one FormConfig per app/form-type combination can be active at a time. Creating a new active config deactivates the previous one.
+- `Version`: auto-incremented on each update, enabling form versioning.
## Field types
@@ -60,10 +64,10 @@ type FormField struct {
}
```
-- **`Key`** — the metadata key where the field value is stored in `User.Metadata`. Must be unique within the form.
-- **`Label`** — display label shown to the user.
-- **`Options`** — only used for `select`, `radio`, and `checkbox` types.
-- **`Order`** — controls the display order of fields (ascending).
+- `Key`: the metadata key where the field value is stored in `User.Metadata`. Must be unique within the form.
+- `Label`: display label shown to the user.
+- `Options`: only used for `select`, `radio`, and `checkbox` types.
+- `Order`: controls the display order of fields (ascending).
### SelectOption
@@ -106,7 +110,7 @@ Validation is enforced at the engine level during signup. If any field fails val
### Via HTTP API
-`POST /v1/auth/forms`
+`POST /v1/forms`
```json
{
@@ -234,7 +238,8 @@ When a user signs up with an active FormConfig, the engine:
{
"email": "alice@example.com",
"password": "Secure!Pass99",
- "name": "Alice Liddell",
+ "first_name": "Alice",
+ "last_name": "Liddell",
"app_id": "myapp",
"metadata": {
"company": "Acme Corp",
@@ -271,9 +276,9 @@ If a custom field fails validation:
Each FormConfig has a `Version` field that auto-increments when the form is updated. This enables:
-- **Audit trail** — know which version of the form a user signed up with
-- **Migration** — gradually roll out field changes without affecting existing users
-- **Rollback** — reactivate a previous version if needed
+- Audit trail: know which version of the form a user signed up with
+- Migration: gradually roll out field changes without affecting existing users
+- Rollback: reactivate a previous version if needed
To update a form, create a new FormConfig with `Active: true`. The engine deactivates the previous active config and activates the new one.
@@ -314,7 +319,7 @@ appBConfig := &formconfig.FormConfig{
Clients (including the Authsome UI) fetch the active form configuration to render the signup form dynamically:
-`GET /v1/auth/forms/active?app_id=myapp&form_type=signup`
+`GET /v1/forms/active?app_id=myapp&form_type=signup`
```json
{
diff --git a/docs/content/docs/dynamic-forms/form-editor.mdx b/docs/content/docs/dynamic-forms/form-editor.mdx
index 8084b83d..de8ddbdb 100644
--- a/docs/content/docs/dynamic-forms/form-editor.mdx
+++ b/docs/content/docs/dynamic-forms/form-editor.mdx
@@ -3,15 +3,19 @@ title: Form Editor
description: Visual dashboard editor for building and previewing custom signup forms without code.
---
+
+The HTTP routes this page shows don't exist. Neither the standalone API nor the Forge extension mounts form or branding routes. Manage them from Go with the engine methods (`GetSignupFormConfig`, `SaveSignupFormConfig`, `DeleteSignupFormConfig`, `ListFormConfigs`, `GetOrgBranding`, `SaveOrgBranding`, `DeleteOrgBranding`) or through the Forge dashboard, which edits them over its contract. Clients get the app's branding from `GET /v1/client-config`.
+
+
The Authsome Dashboard includes a visual form editor that lets non-technical users create, modify, and preview custom signup forms. The editor produces `FormConfig` records (see [Custom Schemas](/docs/dynamic-forms/custom-schemas)) and publishes them to the Authsome engine.
## Overview
The form editor provides a drag-and-drop interface for building signup forms. It is available in the Authsome Dashboard under **Settings > Forms** for each application. The editor consists of three panels:
-1. **Field palette** — available field types that can be added to the form
-2. **Form canvas** — the current form layout with fields in display order
-3. **Properties panel** — configuration for the selected field (label, validation, options)
+1. Field palette: available field types that can be added to the form
+2. Form canvas: the current form layout with fields in display order
+3. Properties panel: configuration for the selected field (label, validation, options)
## Adding fields
@@ -114,7 +118,7 @@ To revert to a previous form version:
The form editor generates `FormConfig` records that the Authsome UI consumes to render signup forms. The UI components:
-1. Fetch the active FormConfig via `GET /v1/auth/forms/active`
+1. Fetch the active FormConfig via `GET /v1/forms/active`
2. Render each `FormField` using the appropriate input component based on `Type`
3. Apply client-side validation rules from the `Validation` object
4. Submit metadata values alongside the standard signup fields
diff --git a/docs/content/docs/environments/cloning.mdx b/docs/content/docs/environments/cloning.mdx
index 3cd51f5b..40a6e338 100644
--- a/docs/content/docs/environments/cloning.mdx
+++ b/docs/content/docs/environments/cloning.mdx
@@ -1,167 +1,74 @@
---
title: Environment Cloning
-description: Clone an existing environment's configuration to create a new environment, without copying user data.
+description: Create a new environment from an existing one's settings, roles, permissions and webhooks, without user data.
---
-Environment cloning creates a new environment pre-configured with the settings, plugins, webhooks, custom form schemas, branding, and RBAC roles of an existing environment. User data -- accounts, sessions, and organizations -- is never cloned. Cloning is the fastest way to spin up a new staging or preview environment that matches production configuration.
+Cloning creates a new environment from an existing one. It copies the settings, the RBAC roles with their permissions, and the webhooks. It never copies users, sessions, organizations, API keys or other user data.
## What gets cloned
| Resource | Cloned | Notes |
|----------|--------|-------|
-| Environment settings | Yes | `SessionTTL`, `MFARequired`, `LockoutConfig`, etc. |
-| Plugin configuration | Yes | Enabled plugins and their per-env settings |
-| Custom form schemas | Yes | All field definitions and validation rules |
-| Branding config | Yes | Logo, colors, fonts, custom CSS |
-| RBAC roles and permissions | Yes | All roles and their permission sets |
-| Webhooks | Yes | Subscriptions, but new signing secrets are generated |
-| IP control rules | Yes | Whitelist and blacklist entries |
-| Rate limit overrides | Yes | Per-endpoint limit overrides |
-| Users | No | Never cloned -- each environment has its own users |
-| Sessions | No | Each environment manages its own sessions |
-| Organizations | No | Org data is environment-scoped and is not cloned |
-| API keys | No | API keys are not cloned -- create new ones for the new env |
-| Audit events | No | Audit trails are environment-specific |
-
-## Cloning an environment
+| Settings | Yes | Merged in order: the new type's defaults, then the source's settings, then `SettingsOverride` |
+| Roles and permissions | Yes | New IDs, parents before children, parent links remapped |
+| Webhooks | Yes | Same events, active flag and signing secret; the URL can be overridden |
+| Users, sessions, organizations, API keys | No | |
+| Form configs and branding | No | |
+
+The new environment is never the default, and its color is its type's default color.
+
+## Cloning from Go
```go
-import "github.com/xraph/authsome/env"
+import "github.com/xraph/authsome/environment"
-result, err := auth.Environments().Clone(ctx, &env.CloneInput{
+result, err := eng.CloneEnvironment(ctx, environment.CloneRequest{
SourceEnvID: productionEnvID,
Name: "Staging",
- Type: env.EnvironmentTypeStaging,
- Description: "Pre-production staging environment, cloned from production",
- Color: "#F59E0B",
- // Optional: override specific settings that differ from the source.
- SettingsOverrides: &env.EnvironmentSettings{
- SelfRegistration: true, // allow test signups in staging
- MFARequired: false, // don't require MFA in staging
- LockoutEnabled: false, // disable lockout during testing
- SessionTTL: 1 * time.Hour,
+ Slug: "staging-2",
+ Type: environment.TypeStaging,
+ Description: "Cloned from production",
+ SettingsOverride: &environment.Settings{
+ LockoutEnabled: ptr(false), // ptr is your own helper
},
+ WebhookURLOverride: "https://staging-hooks.example.com/authsome",
})
if err != nil {
return err
}
-fmt.Printf("cloned environment: id=%s slug=%s\n", result.ID, result.Slug)
-```
-
-### CloneInput fields
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `SourceEnvID` | `id.ID` | Yes | The environment to clone from |
-| `Name` | `string` | Yes | Display name for the new environment |
-| `Type` | `EnvironmentType` | Yes | Type of the new environment |
-| `Slug` | `string` | No | URL-safe slug; auto-generated from name if omitted |
-| `Description` | `string` | No | Description for the new environment |
-| `Color` | `string` | No | Hex color; defaults to the type's default color |
-| `SettingsOverrides` | `*EnvironmentSettings` | No | Settings that differ from the source; non-nil fields override |
-| `CloneWebhooks` | `bool` | No | Default `true`; set to `false` to skip webhook cloning |
-| `CloneRoles` | `bool` | No | Default `true`; set to `false` to skip role cloning |
-| `CloneBranding` | `bool` | No | Default `true`; set to `false` to skip branding cloning |
-
-## What happens during cloning
-
-Cloning is executed in a transaction:
-
-1. A new `Environment` record is created with the provided name, type, and settings.
-2. Settings from the source environment are copied, then `SettingsOverrides` are applied.
-3. All custom form schemas from the source are duplicated in the new environment.
-4. Branding configuration is copied to the new environment.
-5. All RBAC roles and their permissions are duplicated. Role IDs are new; names and structures are identical.
-6. Webhooks are copied. New signing secrets are generated for each webhook (the originals are not replicated -- they are stored as hashes and cannot be copied).
-7. IP control rules are copied.
-8. The `environment.cloned` webhook event is emitted on the source environment.
-
-
-Because webhooks get new signing secrets, you must retrieve and store the new secrets from the clone response. The secrets are included in `result.WebhookSecrets` as a map of `webhookID → secret`.
-
-
-## Retrieving new webhook secrets after cloning
-
-```go
-result, err := auth.Environments().Clone(ctx, &env.CloneInput{
- SourceEnvID: productionEnvID,
- Name: "Preview-PR-42",
- Type: env.EnvironmentTypeCustom,
-})
-if err != nil {
- return err
-}
-
-// result.WebhookSecrets contains new secrets for each cloned webhook.
-for webhookID, secret := range result.WebhookSecrets {
- fmt.Printf(" webhook %s: new secret = %s\n", webhookID, secret)
- // Store these secrets in your secrets manager.
- secretsManager.Set(fmt.Sprintf("authsome/webhook/%s/secret", webhookID), secret)
-}
+fmt.Printf("cloned %s: %d roles, %d permissions, %d webhooks\n",
+ result.Environment.ID, result.RolesCloned, result.PermissionsCloned, result.WebhooksCloned)
```
-## Common use cases
+### CloneRequest fields
-### Ephemeral preview environments
+| Field | Description |
+|-------|-------------|
+| `SourceEnvID` | The environment to copy |
+| `Name`, `Slug`, `Type`, `Description` | The new environment |
+| `SettingsOverride` | Settings applied on top of the merged result; non-nil fields win |
+| `WebhookURLOverride` | When set, every cloned webhook points here instead of its original URL |
-For pull request previews, clone production on every PR open and delete it on close:
+### CloneResult
-```go
-// On PR open: create preview env.
-preview, err := auth.Environments().Clone(ctx, &env.CloneInput{
- SourceEnvID: productionEnvID,
- Name: fmt.Sprintf("Preview-PR-%d", pr.Number),
- Type: env.EnvironmentTypeCustom,
- SettingsOverrides: &env.EnvironmentSettings{
- SelfRegistration: true,
- MFARequired: false,
- LockoutEnabled: false,
- },
-})
+`Environment` is the new environment. `RoleIDMap`, `PermissionIDMap` and `WebhookIDMap` map each source ID to its copy, and `RolesCloned`, `PermissionsCloned` and `WebhooksCloned` count them.
-// On PR close: clean up.
-err := auth.Environments().Delete(ctx, preview.ID)
-```
+The steps aren't wrapped in a transaction. If a role, permission or webhook fails to copy, the new environment and whatever was copied before the failure stay behind, and `CloneEnvironment` returns the error.
-### Staging refresh
+Cloned webhooks keep the source webhook's signing secret, so a receiver that verifies production signatures accepts the clone's too. Rotate it with `eng.RotateWebhookSecret(ctx, webhookID)` if the copy shouldn't share it.
-When staging configuration drifts from production, clone production over it:
+## Over HTTP
-```go
-// Delete old staging.
-err := auth.Environments().Delete(ctx, oldStagingEnvID)
+`POST /v1/environments/:envId/clone`, where `:envId` is the source:
-// Clone fresh from production.
-_, err = auth.Environments().Clone(ctx, &env.CloneInput{
- SourceEnvID: productionEnvID,
- Name: "Staging",
- Type: env.EnvironmentTypeStaging,
- SettingsOverrides: &env.EnvironmentSettings{
- SelfRegistration: true,
- MFARequired: false,
- },
-})
-```
-
-### Configuration audit
-
-Compare two environments after cloning to verify settings are aligned:
-
-```go
-prod, _ := auth.Environments().GetByID(ctx, productionEnvID)
-staging, _ := auth.Environments().GetByID(ctx, stagingEnvID)
-
-if prod.Settings.SessionTTL != staging.Settings.SessionTTL {
- log.Printf("DRIFT: session TTL differs: prod=%s staging=%s",
- prod.Settings.SessionTTL, staging.Settings.SessionTTL)
+```json
+{
+ "name": "Staging",
+ "slug": "staging-2",
+ "type": "staging",
+ "description": "Cloned from production",
+ "settings": {"lockout_enabled": false},
+ "webhook_url_override": "https://staging-hooks.example.com/authsome"
}
```
-
-## HTTP API endpoints
-
-| Method | Path | Description |
-|--------|------|-------------|
-| `POST` | `/environments/:env_id/clone` | Clone an environment |
-
-The response body includes the new environment object and the `webhook_secrets` map.
diff --git a/docs/content/docs/environments/overview.mdx b/docs/content/docs/environments/overview.mdx
index 2f143de5..daa7385f 100644
--- a/docs/content/docs/environments/overview.mdx
+++ b/docs/content/docs/environments/overview.mdx
@@ -1,233 +1,112 @@
---
title: Environments Overview
-description: Application environments for isolating development, staging, and production authentication data and configuration.
+description: Development, staging and production environments inside one app, with their own users and settings overrides.
---
-Environments let you run multiple isolated authentication contexts within a single Authsome application. Each environment has its own users, sessions, organizations, configuration, and API keys. A production environment's data is completely isolated from the development environment -- you can safely test authentication flows, schema changes, and plugin configurations in dev without touching production.
+An environment splits an app's data. Users, sessions and organizations belong to one environment, and each environment can override parts of the app's configuration. `WithBootstrap` creates three for the platform app: Development (the default), Staging and Production.
## The Environment model
```go
-import "github.com/xraph/authsome/env"
+import "github.com/xraph/authsome/environment"
type Environment struct {
- ID id.ID `json:"id"` // aenv_ prefix
- AppID string `json:"app_id"`
- Name string `json:"name"` // e.g. "Production"
- Slug string `json:"slug"` // e.g. "production"
- Type EnvironmentType `json:"type"`
- Description string `json:"description,omitempty"`
- Color string `json:"color,omitempty"` // hex color for dashboard
- IsDefault bool `json:"is_default"`
- IsActive bool `json:"is_active"`
- Settings EnvironmentSettings `json:"settings"`
- Metadata map[string]string `json:"metadata,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID id.EnvironmentID `json:"id"` // aenv_ prefix
+ AppID id.AppID `json:"app_id"`
+ Name string `json:"name"` // "Production"
+ Slug string `json:"slug"` // "production"
+ Type Type `json:"type"` // development, staging or production
+ IsDefault bool `json:"is_default"`
+ Color string `json:"color,omitempty"`
+ Description string `json:"description,omitempty"`
+ Settings *Settings `json:"settings,omitempty"`
+ ClonedFrom id.EnvironmentID `json:"cloned_from,omitempty"`
+ Metadata Metadata `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
```
-| Field | Description |
-|-------|-------------|
-| `ID` | Globally unique identifier with `aenv_` prefix |
-| `Name` | Human-readable name, e.g. `"Production"`, `"Staging"`, `"Development"` |
-| `Slug` | URL-safe identifier used in API calls and environment switching, e.g. `"production"` |
-| `Type` | One of the built-in types or `"custom"` |
-| `Color` | Hex color code for visual identification in the dashboard (`#EF4444` for prod, `#F59E0B` for staging) |
-| `IsDefault` | When `true`, this environment is used when no environment is specified in API calls |
-| `IsActive` | When `false`, the environment is suspended and no authentication operations can complete |
-| `Settings` | Per-environment configuration overrides |
-
-## Environment types
-
-```go
-const (
- EnvironmentTypeDevelopment EnvironmentType = "development"
- EnvironmentTypeStaging EnvironmentType = "staging"
- EnvironmentTypeProduction EnvironmentType = "production"
- EnvironmentTypeCustom EnvironmentType = "custom"
-)
-```
-
-| Type | Default color | Typical use |
-|------|--------------|-------------|
-| `development` | `#3B82F6` (blue) | Local development and unit testing |
-| `staging` | `#F59E0B` (amber) | Pre-production QA and integration testing |
-| `production` | `#EF4444` (red) | Live user-facing environment |
-| `custom` | `#8B5CF6` (purple) | Additional environments (preview, canary, region-specific) |
-
-
-Production environments are marked with a red indicator in the Authsome dashboard. Authsome will also show a confirmation dialog before performing destructive operations (like deleting users or revoking all sessions) in production environments.
-
+| Type | Default color |
+|------|---------------|
+| `development` | `#22c55e` (green) |
+| `staging` | `#eab308` (yellow) |
+| `production` | `#ef4444` (red) |
## Environment settings
-Each environment carries its own settings that override the application defaults:
-
-```go
-type EnvironmentSettings struct {
- // Session settings.
- SessionTTL time.Duration `json:"session_ttl"` // default 7d
- RefreshTokenTTL time.Duration `json:"refresh_token_ttl"` // default 30d
- MaxSessionsPerUser int `json:"max_sessions_per_user"` // 0 = unlimited
- IdleSessionTimeout time.Duration `json:"idle_session_timeout"` // 0 = disabled
-
- // Password settings.
- MinPasswordLength int `json:"min_password_length"` // default 8
- RequireSpecialChars bool `json:"require_special_chars"`
- RequireUppercase bool `json:"require_uppercase"`
- RequireNumbers bool `json:"require_numbers"`
-
- // Auth settings.
- MFARequired bool `json:"mfa_required"`
- AllowedAuthMethods []string `json:"allowed_auth_methods"` // nil = all enabled plugins
- SelfRegistration bool `json:"self_registration"` // default true
-
- // Security settings.
- IPBinding string `json:"ip_binding"` // "disabled", "subnet", "strict"
- LockoutEnabled bool `json:"lockout_enabled"`
- LockoutMaxAttempts int `json:"lockout_max_attempts"`
- LockoutDuration time.Duration `json:"lockout_duration"`
-}
-```
-
-## CRUD operations
-
-### Create an environment
+`Settings` holds per-environment overrides. Every field is optional; nil inherits.
```go
-import "github.com/xraph/authsome/env"
-
-environment, err := auth.Environments().Create(ctx, &env.CreateInput{
- Name: "Preview",
- Type: env.EnvironmentTypeCustom,
- Description: "Short-lived preview environments for pull requests",
- Color: "#10B981",
- Settings: env.EnvironmentSettings{
- SessionTTL: 2 * time.Hour,
- SelfRegistration: false,
- MFARequired: false,
- LockoutEnabled: false,
- },
-})
-if err != nil {
- return err
-}
-fmt.Printf("created environment: id=%s slug=%s\n", environment.ID, environment.Slug)
-```
-
-### Get an environment
-
-```go
-// By ID.
-e, err := auth.Environments().GetByID(ctx, envID)
+type Settings struct {
+ SkipEmailVerification *bool
+ AllowTestCredentials *bool
-// By slug.
-e, err := auth.Environments().GetBySlug(ctx, "production")
+ TokenTTLSeconds *int
+ RefreshTokenTTLSeconds *int
+ TokenExchangeTTLSeconds *int
-// The default environment for the app.
-e, err := auth.Environments().GetDefault(ctx)
-```
-
-### Update an environment
+ PasswordMinLength *int
+ CheckBreached *bool
-```go
-updated, err := auth.Environments().Update(ctx, envID, &env.UpdateInput{
- Name: "Production (US East)",
- Color: "#DC2626",
- Settings: &env.EnvironmentSettings{
- SessionTTL: 14 * 24 * time.Hour,
- MFARequired: true,
- LockoutMaxAttempts: 5,
- LockoutDuration: 30 * time.Minute,
- },
-})
-```
+ RateLimitEnabled *bool
+ SignInRateLimit *int
+ SignUpRateLimit *int
+ RateLimitWindowSecs *int
-### Set the default environment
+ LockoutEnabled *bool
+ LockoutMaxAttempts *int
-```go
-// Make this environment the default for the application.
-err := auth.Environments().SetDefault(ctx, envID)
-```
-
-Only one environment can be the default at a time. Setting a new default automatically clears the flag from the previous default.
-
-### Delete an environment
-
-```go
-err := auth.Environments().Delete(ctx, envID)
+ WebhookURLOverride string
+ OAuthOverrides map[string]OAuthProviderOverride // per-provider client IDs
+}
```
-
-Deleting an environment permanently removes all users, sessions, organizations, API keys, and audit events associated with it. This cannot be undone. The production and default environments cannot be deleted.
-
-
-### Deactivate / activate
+Each type starts from defaults (`environment.DefaultSettingsForType`). Development skips email verification, allows test credentials, sets a 4-character password minimum and turns rate limiting and lockout off. Staging turns rate limiting and lockout on, and production adds the breached-password check.
-```go
-// Suspend an environment -- no authentication operations can complete.
-err := auth.Environments().Deactivate(ctx, envID)
-
-// Reactivate a suspended environment.
-err := auth.Environments().Activate(ctx, envID)
-```
+## Go API
-Deactivating an environment is useful for shutting down a preview environment without deleting its data -- you can reactivate it later.
-
-### List environments
+The methods are on the engine:
```go
-environments, err := auth.Environments().List(ctx)
-
-for _, e := range environments {
- fmt.Printf(" %s (type=%s default=%v active=%v)\n",
- e.Name, e.Type, e.IsDefault, e.IsActive)
+env := &environment.Environment{
+ AppID: appID,
+ Name: "Preview",
+ Slug: "preview",
+ Type: environment.TypeDevelopment,
+}
+if err := eng.CreateEnvironment(ctx, env); err != nil {
+ return err
}
```
-## Switching environments in API calls
-
-When making API calls, specify the target environment using the `X-Authsome-Environment` header or the `env` query parameter:
-
-```http
-POST /api/v1/auth/signin
-X-Authsome-Environment: staging
-```
+`CreateEnvironment` fills in the ID and the type's color when you leave them empty.
-```http
-POST /api/v1/auth/signin?env=staging
-```
+| Method | Description |
+|--------|-------------|
+| `CreateEnvironment(ctx, *Environment)` | Create |
+| `GetEnvironment(ctx, envID)` | Get by ID |
+| `GetEnvironmentBySlug(ctx, appID, slug)` | Get by slug |
+| `GetDefaultEnvironment(ctx, appID)` | The app's default |
+| `ListEnvironments(ctx, appID)` | All of an app's environments |
+| `UpdateEnvironment(ctx, *Environment)` | Save changes |
+| `SetDefaultEnvironment(ctx, appID, envID)` | Make one the default |
+| `DeleteEnvironment(ctx, envID)` | Delete. Refused for the default environment |
+| `CloneEnvironment(ctx, environment.CloneRequest)` | Copy roles, permissions and webhooks into a new one; see [Cloning](/docs/environments/cloning) |
-When neither is provided, the default environment is used.
+## HTTP API
-In Go code, inject the environment ID into the context:
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/v1/environments` | List |
+| `POST` | `/v1/environments` | Create (`name`, `slug`, `type`, optional `color`, `description`, `settings`, `app_id`) |
+| `GET`, `PATCH`, `DELETE` | `/v1/environments/:envId` | Read, update or delete |
+| `GET`, `PATCH` | `/v1/environments/:envId/settings` | Read or change the settings overrides |
+| `POST` | `/v1/environments/:envId/clone` | Clone |
+| `POST` | `/v1/environments/:envId/set-default` | Make it the default |
-```go
-ctx = authsome.WithEnvID(ctx, envID)
-// All subsequent auth.* calls use this environment.
-user, err := auth.Users().GetByEmail(ctx, "alice@example.com")
-```
+## Which environment a request uses
-## HTTP API endpoints
+The HTTP sign-up and sign-in requests have no environment field: they use the app's default environment. From Go, set `EnvID` on `account.SignUpRequest` or `account.SignInRequest`; an empty one also means the default.
-| Method | Path | Description |
-|--------|------|-------------|
-| `POST` | `/environments` | Create a new environment |
-| `GET` | `/environments` | List all environments |
-| `GET` | `/environments/:env_id` | Get an environment by ID |
-| `GET` | `/environments/slug/:slug` | Get an environment by slug |
-| `PATCH` | `/environments/:env_id` | Update an environment |
-| `DELETE` | `/environments/:env_id` | Delete an environment |
-| `POST` | `/environments/:env_id/activate` | Activate a suspended environment |
-| `POST` | `/environments/:env_id/deactivate` | Suspend an environment |
-| `POST` | `/environments/:env_id/set-default` | Make an environment the default |
-
-## Events emitted
-
-| Event | Trigger |
-|-------|---------|
-| `environment.created` | A new environment was created |
-| `environment.updated` | Environment settings or details were changed |
-| `environment.deleted` | An environment was permanently deleted |
-| `environment.cloned` | An environment was cloned from another |
+The `middleware` package has an `EnvironmentMiddleware` that resolves an environment from an `X-Environment-ID` header (then an API key prefix, then the default) and puts it on the context, but neither the standalone API nor the Forge extension installs it. Add it to your router yourself if you want it, and read the result with `middleware.EnvIDFrom`, `EnvironmentFrom` or `EnvironmentSettingsFrom`.
diff --git a/docs/content/docs/environments/per-env-isolation.mdx b/docs/content/docs/environments/per-env-isolation.mdx
index 7680301e..cc7185f1 100644
--- a/docs/content/docs/environments/per-env-isolation.mdx
+++ b/docs/content/docs/environments/per-env-isolation.mdx
@@ -1,192 +1,53 @@
---
title: Per-Environment Isolation
-description: How Authsome enforces strict data isolation between environments, including users, sessions, organizations, and per-environment session configuration.
+description: What an environment keeps separate, how the environment is chosen, and how its settings layer over the app's.
---
-Every entity in Authsome is stamped with both an `app_id` and an `env_id`. This means users in `development` and users in `production` are completely separate records -- there is no overlap, no accidental cross-contamination, and no way for a query in one environment to accidentally touch another environment's data.
+Users, sessions and organizations carry both an `app_id` and an `env_id`. The same email address can have one account in development and a separate one in production, with different IDs, passwords and sessions.
-## Isolation model
+## How lookups are scoped
-```
-Application (AppID)
- ├── Environment: production (EnvID: aenv_prod...)
- │ ├── Users (scoped to env)
- │ ├── Sessions (scoped to env)
- │ ├── Organizations (scoped to env)
- │ ├── API Keys (scoped to env)
- │ └── Audit Events (scoped to env)
- │
- ├── Environment: staging (EnvID: aenv_stg...)
- │ ├── Users (completely separate from production)
- │ ├── Sessions
- │ └── ...
- │
- └── Environment: development (EnvID: aenv_dev...)
- └── ...
-```
-
-The `env_id` is injected into the `context.Context` at the HTTP middleware layer and is automatically applied to every database query. Cross-environment access is structurally impossible -- every store method takes an `envID` parameter and filters by it at the SQL level.
-
-## Environment context injection
-
-### In HTTP handlers
-
-When a request arrives, Authsome resolves the environment from the request and injects it into the context:
-
-```go
-import "github.com/xraph/authsome/middleware"
-
-// Use X-Authsome-Environment header or env query parameter.
-router.Use(middleware.EnvironmentContext(auth))
-```
-
-The middleware resolution order:
-1. `X-Authsome-Environment` HTTP header (slug or ID)
-2. `env` query parameter
-3. JWT claim `env_id`
-4. Default environment for the application
-
-### In Go code
-
-```go
-import "github.com/xraph/authsome"
-
-// Inject by environment ID.
-ctx = authsome.WithEnvID(ctx, envID)
+The store methods that find a user take the environment explicitly, for example `GetUserByEmail(ctx, appID, envID, email)` on `store.Store`. Sign-up and sign-in resolve the environment before they look anything up:
-// Inject by slug (resolved at injection time).
-ctx, err = authsome.WithEnvSlug(ctx, auth, "production")
-
-// All subsequent calls use this environment.
-user, err := auth.Users().GetByEmail(ctx, "alice@example.com")
-```
-
-## Isolated user accounts
-
-A user email address can exist in multiple environments simultaneously as separate, independent accounts. Alice can have an account in `development` and a completely separate account in `production`. They share nothing: different user IDs, different passwords, different sessions, different MFA configurations.
+- Over HTTP, the request has no environment field, so the app's default environment is used. With `WithBootstrap`, that's Development until you call `SetDefaultEnvironment`.
+- From Go, set `EnvID` on `account.SignUpRequest` or `account.SignInRequest`. An empty `EnvID` also means the default.
```go
-// In production environment.
-prodCtx := authsome.WithEnvID(ctx, productionEnvID)
-prodUser, err := auth.Users().GetByEmail(prodCtx, "alice@example.com")
-// prodUser.ID = ausr_prod123...
-
-// In development environment.
-devCtx := authsome.WithEnvID(ctx, developmentEnvID)
-devUser, err := auth.Users().GetByEmail(devCtx, "alice@example.com")
-// devUser.ID = ausr_dev456... (completely different user record)
-```
-
-## Isolated sessions
-
-Sessions are scoped to an environment. A session token created in `staging` cannot be used to authenticate in `production`. Authsome validates the session's `env_id` against the request's environment context and rejects mismatches.
-
-```go
-// Session created in staging.
-stagingCtx := authsome.WithEnvID(ctx, stagingEnvID)
-session, err := auth.Auth().SignIn(stagingCtx, ...)
-// session.EnvID = stagingEnvID
-
-// Attempt to use this session in production.
-prodCtx := authsome.WithEnvID(ctx, productionEnvID)
-_, err = auth.Sessions().Get(prodCtx, session.ID)
-// err = ErrSessionNotFound (the session exists only in staging)
-```
-
-## Isolated organizations
-
-Organizations are scoped to an environment. Organizations and their members in `staging` are separate records from those in `production`. Inviting someone to an organization in `development` does not affect `production`.
-
-## Per-environment session configuration
-
-While environments share the same plugin configuration as the application, each environment can override session-related settings independently:
-
-```go
-import "github.com/xraph/authsome/env"
-
-// Set a short session TTL for development.
-_, err := auth.Environments().Update(ctx, devEnvID, &env.UpdateInput{
- Settings: &env.EnvironmentSettings{
- SessionTTL: 1 * time.Hour, // very short in dev
- RefreshTokenTTL: 4 * time.Hour,
- MaxSessionsPerUser: 5,
- IdleSessionTimeout: 30 * time.Minute,
- },
-})
-
-// Set a longer TTL with MFA required for production.
-_, err = auth.Environments().Update(ctx, prodEnvID, &env.UpdateInput{
- Settings: &env.EnvironmentSettings{
- SessionTTL: 7 * 24 * time.Hour, // 7 days
- RefreshTokenTTL: 30 * 24 * time.Hour, // 30 days
- MFARequired: true,
- MaxSessionsPerUser: 10,
- IPBinding: "subnet",
- },
+u, sess, err := eng.SignUp(ctx, &account.SignUpRequest{
+ AppID: appID,
+ EnvID: productionEnvID,
+ Email: "alice@example.com",
+ Password: "SecureP@ss123",
})
```
-### Session config override precedence
+Organizations get their environment from the `EnvID` you set when you create them.
-```
-Request-time session config
- 1. Environment settings (highest priority)
- 2. Application-level defaults
- 3. Authsome built-in defaults (lowest priority)
-```
-
-## Per-environment allowed auth methods
+## Choosing the environment per request
-Different environments can enable different authentication methods. For example, in development you might disable SSO to avoid setting up SAML/OIDC connections, while production has SSO required:
+`middleware.EnvironmentMiddleware` resolves an environment from the `X-Environment-ID` header, then from an API key prefix if you give it a detector, then from the app's default. It stores the environment and its resolved settings on the context (`middleware.EnvIDFrom`, `EnvironmentFrom`, `EnvironmentSettingsFrom`). Neither the standalone API nor the Forge extension installs it, so the sign-up and sign-in handlers don't consult it; add it for your own handlers:
```go
-// Development: simple password + Google OAuth only.
-_, err := auth.Environments().Update(ctx, devEnvID, &env.UpdateInput{
- Settings: &env.EnvironmentSettings{
- AllowedAuthMethods: []string{"password", "google"},
+router.Use(middleware.EnvironmentMiddleware(middleware.EnvironmentMiddlewareConfig{
+ ResolveEnvironment: func(envID id.EnvironmentID) (*environment.Environment, error) {
+ return eng.GetEnvironment(context.Background(), envID)
},
-})
-
-// Production: password, Google, and SAML SSO.
-_, err = auth.Environments().Update(ctx, prodEnvID, &env.UpdateInput{
- Settings: &env.EnvironmentSettings{
- AllowedAuthMethods: []string{"password", "google", "saml"},
+ ResolveDefault: func(appID id.AppID) (*environment.Environment, error) {
+ return eng.GetDefaultEnvironment(context.Background(), appID)
},
-})
+}))
```
-When `AllowedAuthMethods` is `nil`, all methods provided by registered plugins are enabled.
-
-## Preventing accidental production operations
+It needs the app on the context first (`middleware.AppIDFrom`), which the auth middleware sets for signed-in requests.
-Authsome wraps destructive operations in production environments with an additional confirmation requirement. The HTTP API returns a `424 Failed Dependency` with a `confirmation_required` error when you attempt:
+## Settings layering
-- Deleting all users
-- Revoking all sessions
-- Deleting the environment
-- Changing the environment type from `production` to another type
+Session settings resolve with the most specific value winning:
-To confirm the operation, include the `X-Authsome-Confirm: delete-users` (or appropriate action) header in the repeat request.
+1. The environment's settings (`TokenTTLSeconds`, `RefreshTokenTTLSeconds` and the rest on `environment.Settings`)
+2. The app's session config (`appsessionconfig.Config`, set with `WithAppSessionConfig` or the admin API)
+3. The engine's `Config.Session`
-```go
-// The Go SDK handles confirmations transparently when you set the option.
-err := auth.Users().DeleteAll(prodCtx,
- authsome.WithConfirmDestructive("delete-all-users"),
-)
-```
-
-## Environment labels in tokens
-
-JWTs issued by Authsome include the `env_id` claim so your application can know which environment a token belongs to:
-
-```json
-{
- "sub": "ausr_01h455vb...",
- "env_id": "aenv_01h455vb...",
- "app_id": "myapp",
- "iat": 1718000000,
- "exp": 1718604800
-}
-```
+`eng.SessionConfigForApp(ctx, appID, envID)` returns the merged result. The environment type supplies defaults underneath the environment's own settings; see [Environments Overview](/docs/environments/overview).
-Validate this claim in your application middleware to ensure users from a development environment cannot access production resources.
+Other environment settings cover email verification (`SkipEmailVerification`), the password minimum and breached-password check, rate limits, lockout, a webhook URL override and per-provider OAuth client overrides (`OAuthOverrides`).
diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx
index 314f5ef1..d847b581 100644
--- a/docs/content/docs/getting-started.mdx
+++ b/docs/content/docs/getting-started.mdx
@@ -3,36 +3,27 @@ title: Getting Started
description: Install Authsome and run your first auth flow in five minutes.
---
-This guide walks you through installing Authsome, creating an engine with password authentication, starting a local server, and exercising the sign-up, sign-in, and session endpoints with `curl`. By the end you will have a working auth service you can extend with additional plugins.
+This guide installs Authsome, builds an engine with password sign-in, serves its API locally and walks through sign-up with `curl`. Everything runs in memory, so you don't need a database.
## Prerequisites
-- **Go 1.24 or later** — the module requires recent generics and `log/slog` support
+- Go 1.26 or later
- A Go module (`go mod init your/module`)
-- `curl` or any HTTP client for testing
+- `curl` and `openssl`
## Install
-```bash
-go get github.com/xraph/authsome
-```
-
-This installs the core engine, all built-in plugins, and the `store/memory` backend. Add a database-backed store separately when needed:
+Authsome records every security event to [Chronicle](https://github.com/xraph/chronicle) and checks permissions with [Warden](https://github.com/xraph/warden). The engine refuses to start without both, so install them too:
```bash
-# PostgreSQL
-go get github.com/xraph/authsome/store/postgres
-
-# SQLite
-go get github.com/xraph/authsome/store/sqlite
-
-# MongoDB
-go get github.com/xraph/authsome/store/mongo
+go get github.com/xraph/authsome@latest github.com/xraph/chronicle@latest github.com/xraph/warden@latest
```
-## Step 1 — Create the engine
+The core engine, every plugin and the stores (`store/memory`, `store/postgres`, `store/sqlite`, `store/mongo`) are packages in that one module.
+
+## Step 1: Create the engine
-Create a `main.go` file. Start with the in-memory store so you can run immediately without a database.
+Save this as `main.go`:
```go
package main
@@ -40,11 +31,19 @@ package main
import (
"context"
"log"
- "log/slog"
"net/http"
- "time"
+
+ "github.com/xraph/chronicle"
+ chstore "github.com/xraph/chronicle/store"
+ chmemory "github.com/xraph/chronicle/store/memory"
+ "github.com/xraph/forge"
+ "github.com/xraph/warden"
+ wmemory "github.com/xraph/warden/store/memory"
"github.com/xraph/authsome"
+ "github.com/xraph/authsome/api"
+ "github.com/xraph/authsome/bridge/chronicleadapter"
+ "github.com/xraph/authsome/middleware"
"github.com/xraph/authsome/plugins/password"
"github.com/xraph/authsome/store/memory"
)
@@ -52,244 +51,255 @@ import (
func main() {
ctx := context.Background()
- // In-memory store is perfect for local development.
- // Swap for postgres.New() or sqlite.New() before deploying.
- memStore := memory.New()
+ // Authsome records every security event to Chronicle and checks
+ // permissions with Warden, so both are required.
+ trail, err := chronicle.New(chronicle.WithStore(chstore.NewAdapter(chmemory.New())))
+ if err != nil {
+ log.Fatal(err)
+ }
+ authz, err := warden.NewEngine(warden.WithStore(wmemory.New()))
+ if err != nil {
+ log.Fatal(err)
+ }
- eng, err := authsome.New(
- authsome.WithStore(memStore),
- authsome.WithLogger(slog.Default()),
+ engine, err := authsome.NewEngine(
+ authsome.WithStore(memory.New()),
+ authsome.WithChronicle(chronicleadapter.New(trail)),
+ authsome.WithWarden(authz),
+ authsome.WithDisableMigrate(), // the memory store has nothing to migrate
authsome.WithPlugin(password.New()),
- authsome.WithConfig(authsome.Config{
- AppID: "myapp",
- BasePath: "/v1/auth",
- Session: authsome.SessionConfig{
- TokenTTL: 1 * time.Hour,
- RefreshTokenTTL: 30 * 24 * time.Hour,
- },
- Password: authsome.PasswordConfig{
- MinLength: 12,
- RequireUppercase: true,
- RequireLowercase: true,
- RequireDigit: true,
- },
- }),
+ authsome.WithBootstrap(authsome.WithBootstrapAppName("Acme")),
)
if err != nil {
log.Fatal(err)
}
```
-The `password.New()` call registers the password strategy and contributes the `/signup`, `/signin`, `/forgot-password`, `/reset-password`, and `/change-password` route handlers.
+`password.New()` registers the password strategy. The account routes (`/signup`, `/signin`, `/forgot-password` and so on) come from the core API.
-## Step 2 — Start the engine
+`WithBootstrap` creates a platform app with its default environments and roles when the engine starts, which gives you an app ID to sign up against.
-`Start` runs schema migrations and calls `OnInit` on every registered plugin. Always call `Stop` to flush in-flight operations on shutdown.
+## Step 2: Start the engine
+
+`Start` runs migrations (unless disabled), seeds the bootstrap app and calls `OnInit` on every plugin. Call `Stop` on shutdown.
```go
- if err := eng.Start(ctx); err != nil {
+ if err := engine.Start(ctx); err != nil {
log.Fatal(err)
}
- defer eng.Stop(ctx)
+ defer engine.Stop(ctx)
```
-
-When using the memory store, `Start` is a no-op for migrations since there is no schema to apply. With `postgres` or `sqlite`, `Start` runs embedded SQL migrations automatically unless `WithDisableMigrate()` is passed.
-
-
-## Step 3 — Register API routes
+## Step 3: Serve the API
-Authsome auto-registers all plugin-contributed routes on any `http.ServeMux`. Call `RegisterRoutes` after `Start`.
+The HTTP API is a set of Forge routes. Mount them on a Forge router with the auth middleware in front:
```go
- mux := http.NewServeMux()
- eng.RegisterRoutes(mux)
-
- log.Println("listening on :8080")
- if err := http.ListenAndServe(":8080", mux); err != nil {
+ router := forge.NewRouter()
+ router.Use(middleware.AuthMiddleware(engine.ResolveSessionByToken, engine.ResolveUser, nil))
+ if err := api.New(engine).RegisterRoutes(router); err != nil {
log.Fatal(err)
}
+ // Plugins mount their own routes (/v1/orgs, /v1/mfa, /v1/social and so on).
+ for _, rp := range engine.Plugins().RouteProviders() {
+ if err := rp.RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ log.Printf("listening on :8080, app_id=%s", engine.PlatformAppID())
+ log.Fatal(http.ListenAndServe(":8080", router.Handler()))
}
```
-Your application now serves the following endpoints under `/v1/auth`:
+`api.New(engine).RegisterRoutes` mounts the core routes. Plugins with HTTP endpoints implement `plugin.RouteProvider`, and the loop mounts theirs; the password plugin has none of its own, but magic links, MFA, social login and organizations do. The Forge extension does both for you.
+
+The core routes live under `/v1`:
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/v1/auth/signup` | Create account and return session |
-| `POST` | `/v1/auth/signin` | Sign in with email + password |
-| `POST` | `/v1/auth/signout` | Revoke the current session |
-| `POST` | `/v1/auth/refresh` | Exchange refresh token for new tokens |
-| `GET` | `/v1/auth/me` | Return the currently authenticated user |
-| `POST` | `/v1/auth/forgot-password` | Request a password reset email |
-| `POST` | `/v1/auth/reset-password` | Confirm reset with token + new password |
-| `POST` | `/v1/auth/change-password` | Change password (requires current password) |
+| `POST` | `/v1/signup` | Create an account and return a session |
+| `POST` | `/v1/signin` | Sign in with email and password |
+| `POST` | `/v1/signout` | Revoke the current session |
+| `POST` | `/v1/refresh` | Exchange a refresh token for new tokens |
+| `GET` | `/v1/me` | The signed-in user |
+| `PATCH` | `/v1/me` | Update the signed-in user |
+| `POST` | `/v1/verify-email` | Confirm an email address |
+| `POST` | `/v1/forgot-password` | Request a password reset email |
+| `POST` | `/v1/reset-password` | Confirm a reset with its token and a new password |
+| `POST` | `/v1/change-password` | Change the password (needs the current one) |
+
+The OpenAPI spec for everything you've mounted is at `/.well-known/authsome/openapi`.
-## Step 4 — Test with curl
+## Step 4: Run it
-Run the server (`go run .`), then exercise the endpoints.
+Authsome encrypts the tokens it stores, so it needs a key. Give it 64 hex characters:
+
+```bash
+go mod tidy
+export AUTHSOME_TOKEN_ENCRYPTION_KEY=$(openssl rand -hex 32)
+go run .
+# listening on :8080, app_id=aapp_...
+```
+
+You can also pass an encryptor with `authsome.WithTokenEncryptor`, or set `AUTHSOME_TOKEN_ENCRYPTION_KEYS` to `kid:hex` pairs when you rotate keys.
+
+## Step 5: Test with curl
### Sign up
+Put the `app_id` from the log line in the body:
+
```bash
-curl -s -X POST http://localhost:8080/v1/auth/signup \
+curl -s -X POST http://localhost:8080/v1/signup \
-H "Content-Type: application/json" \
- -d '{"email":"alice@example.com","password":"Secure1234","app_id":"myapp"}' | jq .
+ -d '{"app_id":"aapp_...","email":"alice@example.com","password":"SecureP@ss123"}'
```
Response:
```json
{
+ "session_token": "0d6283f1...",
+ "refresh_token": "ebad821f...",
+ "expires_at": "2026-10-14T18:29:39Z",
"user": {
- "id": "ausr_01j9...",
+ "id": "ausr_01m4...",
+ "app_id": "aapp_01m4...",
+ "env_id": "aenv_01m4...",
"email": "alice@example.com",
- "app_id": "aapp_01j9...",
- "created_at": "2024-11-01T10:00:00Z"
- },
- "session": {
- "id": "ases_01j9...",
- "token": "a3f8c9d4...",
- "refresh_token": "d72b1ef8...",
- "expires_at": "2024-11-01T11:00:00Z"
+ "email_verified": false
}
}
```
-### Sign in
+Browser and mobile clients usually send an `X-Publishable-Key` header instead of the app ID.
+
+### Call /me
```bash
-TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/signin \
- -H "Content-Type: application/json" \
- -d '{"email":"alice@example.com","password":"Secure1234","app_id":"myapp"}' \
- | jq -r '.session.token')
+curl -s http://localhost:8080/v1/me \
+ -H "Authorization: Bearer "
```
-### Call the /me endpoint
+### Sign in
-```bash
-curl -s http://localhost:8080/v1/auth/me \
- -H "Authorization: Bearer $TOKEN" | jq .
+`/v1/signin` refuses an account whose email isn't verified, because the `auth.require_email_verification` setting is on by default:
+
+```json
+{"code":403,"error":"please verify your email address before signing in","type":"email_not_verified"}
```
-Response:
+Sign-up issues a 6-digit code for this, but Authsome doesn't send it by itself; the `notification` plugin delivers it through a Herald bridge. For local work, either switch the setting off:
-```json
-{
- "id": "ausr_01j9...",
- "email": "alice@example.com",
- "email_verified": false,
- "app_id": "aapp_01j9...",
- "created_at": "2024-11-01T10:00:00Z",
- "updated_at": "2024-11-01T10:00:00Z"
-}
+```go
+// after engine.Start; needs encoding/json and github.com/xraph/authsome/settings
+_ = engine.Settings().Set(ctx, "auth.require_email_verification",
+ json.RawMessage(`false`), settings.ScopeGlobal, "", "", "", "local-dev")
```
+or take the code from `engine.SendEmailVerification(ctx, user)` and post `{"email": "...", "code": "..."}` to `/v1/verify-email`. After that, sign-in returns the same shape as sign-up.
+
### Refresh tokens
```bash
-REFRESH=$(curl -s -X POST http://localhost:8080/v1/auth/signin \
+curl -s -X POST http://localhost:8080/v1/refresh \
-H "Content-Type: application/json" \
- -d '{"email":"alice@example.com","password":"Secure1234","app_id":"myapp"}' \
- | jq -r '.session.refresh_token')
-
-curl -s -X POST http://localhost:8080/v1/auth/refresh \
- -H "Content-Type: application/json" \
- -d "{\"refresh_token\":\"$REFRESH\"}" | jq .
+ -d '{"refresh_token":""}'
```
-The response includes a new `token` and, if refresh token rotation is enabled (the default), a new `refresh_token`. The old refresh token is immediately invalidated.
+You get a new session token and, with refresh rotation on (the default), a new refresh token. The old one stops working.
-## Step 5 — Add more plugins
+## Step 6: Add more plugins
-The real power of Authsome is in its plugin system. Add plugins at engine construction time.
+Every sign-in method beyond the core is a plugin. Add it next to `password.New()`.
-### Add magic link sign-in
+### Magic links
```go
import "github.com/xraph/authsome/plugins/magiclink"
-eng, err := authsome.New(
- authsome.WithStore(memStore),
- authsome.WithPlugin(password.New()),
- authsome.WithPlugin(magiclink.New(magiclink.Config{
- TokenTTL: 15 * time.Minute,
- })),
- // A mailer bridge is required to deliver magic link emails.
- authsome.WithMailer(myMailer),
-)
+authsome.WithPlugin(magiclink.New(magiclink.Config{
+ // The plugin only sends through this Mailer; it doesn't use the
+ // engine's mailer bridge. Without one, no email goes out.
+ Mailer: magiclink.MailerFunc(func(ctx context.Context, email, token string) error {
+ return sendLoginEmail(ctx, email, "https://myapp.com/magic?token="+token)
+ }),
+})),
```
-### Add Google and GitHub social login
+The client posts the address to `/v1/magic-link/send` and the token to `/v1/magic-link/verify`, which returns a session.
+
+### Google and GitHub sign-in
```go
import "github.com/xraph/authsome/plugins/social"
-eng, err := authsome.New(
- authsome.WithStore(memStore),
- authsome.WithPlugin(password.New()),
- authsome.WithPlugin(social.New(social.Config{
- Providers: []social.Provider{
- {
- Name: "google",
- ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
- ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/google/callback",
- },
- {
- Name: "github",
- ClientID: os.Getenv("GITHUB_CLIENT_ID"),
- ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
- RedirectURL: "https://myapp.com/v1/auth/social/github/callback",
- },
- },
- })),
-)
+authsome.WithPlugin(social.New(social.Config{
+ Providers: []social.Provider{
+ social.NewGoogleProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+ RedirectURL: "https://myapp.com/v1/social/google/callback",
+ }),
+ social.NewGitHubProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GITHUB_CLIENT_ID"),
+ ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
+ RedirectURL: "https://myapp.com/v1/social/github/callback",
+ }),
+ },
+})),
```
-### Add TOTP-based MFA
+### TOTP-based MFA
```go
import "github.com/xraph/authsome/plugins/mfa"
-eng, err := authsome.New(
- authsome.WithStore(memStore),
- authsome.WithPlugin(password.New()),
- authsome.WithPlugin(mfa.New(mfa.Config{
- TOTPIssuer: "MyApp",
- })),
-)
+authsome.WithPlugin(mfa.New(mfa.Config{
+ Issuer: "MyApp",
+})),
```
-## Step 6 — Switch to PostgreSQL
+## Step 7: Switch to PostgreSQL
-When you are ready for a persistent store, swap the backend. The engine API does not change.
+The stores take a [Grove](https://github.com/xraph/grove) database. Open one and pass it to `postgres.New`:
```go
-import "github.com/xraph/authsome/store/postgres"
+import (
+ "github.com/xraph/grove"
+ "github.com/xraph/grove/drivers/pgdriver"
+
+ "github.com/xraph/authsome/store/postgres"
+)
-pgStore, err := postgres.New(ctx, "postgres://user:pass@localhost:5432/myapp_auth")
+pg := pgdriver.New()
+if err := pg.Open(ctx, "postgres://user:pass@localhost:5432/myapp_auth?sslmode=disable"); err != nil {
+ log.Fatal(err)
+}
+db, err := grove.Open(pg)
if err != nil {
log.Fatal(err)
}
-eng, err := authsome.New(
- authsome.WithStore(pgStore),
- // ... same plugins and config
+engine, err := authsome.NewEngine(
+ authsome.WithStore(postgres.New(db)),
+ authsome.WithDriverName("pg"),
+ authsome.WithDB(db),
+ // ... Chronicle, Warden, plugins and bootstrap as before,
+ // without WithDisableMigrate
)
```
-`Start` will apply all pending schema migrations automatically. Existing data is preserved across upgrades.
+`WithDriverName("pg")` tells plugins which migrations to hand over, and `WithDB(db)` gives them the database. Without `WithDB`, plugins that keep their own data (mfa, social, sso, oauth2provider, scim and others) fall back to in-memory stores, so their enrollments and links vanish on restart. Without `WithDriverName`, the tables only plugins create (SSO identities, consents, the OAuth2 provider's tables and others) never get made. The Forge extension sets both for you.
-
-For production deployments, run migrations in a dedicated step rather than relying on auto-migration at startup. Pass `WithDisableMigrate()` to the engine and invoke `eng.Migrate(ctx)` from your deployment pipeline before starting the server.
-
+`Start` applies pending migrations for the core and every registered plugin. To migrate in a separate deployment step instead, build the engine without `WithDisableMigrate()` in that step and call `engine.EnsureMigrated(ctx)`, then pass `WithDisableMigrate()` to the servers. `EnsureMigrated` does nothing on an engine with migrations disabled.
## Next steps
-
-
-
-
+
+
+
+
diff --git a/docs/content/docs/guides/custom-plugin.mdx b/docs/content/docs/guides/custom-plugin.mdx
index aea60b06..009291c8 100644
--- a/docs/content/docs/guides/custom-plugin.mdx
+++ b/docs/content/docs/guides/custom-plugin.mdx
@@ -3,7 +3,7 @@ title: Custom Plugin
description: Build a custom Authsome plugin by implementing lifecycle hook interfaces.
---
-Authsome's plugin system uses Go interfaces -- implement only the hooks you need and register your plugin with the engine. This guide walks through building a complete plugin from scratch.
+Authsome's plugin system uses Go interfaces: implement only the hooks you need and register your plugin with the engine. This guide walks through building a complete plugin from scratch.
## The base interface
@@ -171,9 +171,9 @@ func pgMigrations() *migrate.Group {
// ── Routes ──
func (p *Plugin) RegisterRoutes(router any) error {
- // Register POST /v1/auth/api-tokens (create)
- // Register GET /v1/auth/api-tokens (list)
- // Register DELETE /v1/auth/api-tokens/:id (revoke)
+ // Register POST /v1/api-tokens (create)
+ // Register GET /v1/api-tokens (list)
+ // Register DELETE /v1/api-tokens/:id (revoke)
return nil
}
@@ -234,6 +234,7 @@ import (
)
eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(store),
authsome.WithPlugin(apitoken.New()),
)
@@ -254,8 +255,8 @@ authExt := extension.New(
The engine's plugin registry uses type assertions at registration time to discover which hooks each plugin implements. When an event fires, only plugins implementing that specific hook interface are called. Hooks are invoked sequentially in registration order. If any hook returns an error:
-- **Before hooks** -- the error aborts the operation
-- **After hooks** -- the error is logged but does not abort the operation
+- Before hooks: the error aborts the operation
+- After hooks: the error is logged but does not abort the operation
## Testing your plugin
@@ -273,6 +274,7 @@ import (
func TestPluginRegisters(t *testing.T) {
eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(memory.New()),
authsome.WithPlugin(apitoken.New()),
)
diff --git a/docs/content/docs/guides/custom-store.mdx b/docs/content/docs/guides/custom-store.mdx
index 69c09d92..40112031 100644
--- a/docs/content/docs/guides/custom-store.mdx
+++ b/docs/content/docs/guides/custom-store.mdx
@@ -7,29 +7,34 @@ Every Authsome storage backend implements a single Go interface. This guide show
## The composite interface
-Your store must implement the composite `store.Store` interface, which embeds 12 subsystem stores plus lifecycle methods:
+Your store must implement the composite `store.Store` interface, which embeds every domain store plus three lifecycle methods:
```go
-import "github.com/xraph/authsome/store"
-
type Store interface {
- user.Store
- session.Store
- account.Store
- app.Store
- organization.Store
- device.Store
- webhook.Store
- notification.Store
- apikey.Store
- environment.Store
- formconfig.Store
- formconfig.BrandingStore
- appsessionconfig.Store
-
- Migrate(ctx context.Context, extraGroups ...*migrate.Group) error
- Ping(ctx context.Context) error
- Close() error
+ user.Store
+ session.Store
+ account.Store
+ app.Store
+ organization.Store
+ device.Store
+ webhook.Store
+ notification.Store
+ apikey.Store
+ environment.Store
+ formconfig.Store
+ formconfig.BrandingStore
+ appsessionconfig.Store
+ appclientconfig.Store
+ settings.Store
+ serviceaccount.Store
+ principal.Store
+ KV
+ LegacyTokenHasher
+ Retention
+ Paged
+ Migrate(ctx context.Context, extraGroups ...*migrate.Group) error
+ Ping(ctx context.Context) error
+ Close() error
}
```
@@ -39,12 +44,22 @@ type Store interface {
```go
type Store interface {
- CreateUser(ctx context.Context, u *User) error
- GetUser(ctx context.Context, userID id.UserID) (*User, error)
- GetUserByEmail(ctx context.Context, email string) (*User, error)
- UpdateUser(ctx context.Context, u *User) error
- DeleteUser(ctx context.Context, userID id.UserID) error
- ListUsers(ctx context.Context, filter *ListFilter) ([]*User, int64, error)
+ CreateUser(ctx context.Context, u *User) error
+ GetUser(ctx context.Context, userID id.UserID) (*User, error)
+ GetUserByEmail(ctx context.Context, appID id.AppID, envID id.EnvironmentID, email string) (*User, error)
+ GetUserByPhone(ctx context.Context, appID id.AppID, envID id.EnvironmentID, phone string) (*User, error)
+ GetUserByUsername(ctx context.Context, appID id.AppID, envID id.EnvironmentID, username string) (*User, error)
+ UpdateUser(ctx context.Context, u *User) error
+ DeleteUser(ctx context.Context, userID id.UserID) error
+ ListUsers(ctx context.Context, q *Query) (*List, error)
+ CreateUserWithPrimaryEmail(ctx context.Context, u *User, primary *UserEmail) error
+ AddUserEmail(ctx context.Context, e *UserEmail) error
+ GetUserByAnyEmail(ctx context.Context, appID id.AppID, envID id.EnvironmentID, email string) (*User, error)
+ GetUserEmailRecord(ctx context.Context, appID id.AppID, envID id.EnvironmentID, email string) (*UserEmail, error)
+ GetUserEmails(ctx context.Context, userID id.UserID) ([]*UserEmail, error)
+ MarkUserEmailVerified(ctx context.Context, userID id.UserID, email string) error
+ SetPrimaryEmail(ctx context.Context, userID id.UserID, email string) error
+ DeleteUserEmail(ctx context.Context, userID id.UserID, email string) error
}
```
@@ -52,15 +67,24 @@ type Store interface {
```go
type Store interface {
- CreateSession(ctx context.Context, s *Session) error
- GetSession(ctx context.Context, sessionID id.SessionID) (*Session, error)
- GetSessionByToken(ctx context.Context, token string) (*Session, error)
- GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*Session, error)
- UpdateSession(ctx context.Context, s *Session) error
- DeleteSession(ctx context.Context, sessionID id.SessionID) error
- DeleteUserSessions(ctx context.Context, userID id.UserID) error
- ListUserSessions(ctx context.Context, userID id.UserID) ([]*Session, error)
- CountActiveSessions(ctx context.Context, userID id.UserID) (int64, error)
+ CreateSession(ctx context.Context, s *Session) error
+ GetSession(ctx context.Context, sessionID id.SessionID) (*Session, error)
+ GetSessionByToken(ctx context.Context, token string) (*Session, error)
+ GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*Session, error)
+ UpdateSession(ctx context.Context, s *Session) error
+ RotateSession(ctx context.Context, s *Session, expectedTokenHash string) (bool, error)
+ TouchSession(ctx context.Context, sessionID id.SessionID, lastActivityAt, expiresAt time.Time) error
+ DeleteSession(ctx context.Context, sessionID id.SessionID) error
+ DeleteUserSessions(ctx context.Context, userID id.UserID) error
+ DeleteSessionsByGrant(ctx context.Context, grantID id.AgentGrantID) error
+ ListUserSessions(ctx context.Context, userID id.UserID) ([]*Session, error)
+ ListUserSessionsPage(ctx context.Context, userID id.UserID, opts page.Opts) (page.Page[*Session], error)
+ ListSessions(ctx context.Context, limit int) ([]*Session, error)
+ IsRefreshTokenRevoked(ctx context.Context, tokenHash string) (bool, error)
+ MarkRefreshTokenRevoked(ctx context.Context, tokenHash string, familyID id.SessionFamilyID, reason string) error
+ GetRevokedRefreshTokenFamily(ctx context.Context, tokenHash string) (id.SessionFamilyID, error)
+ RevokeRefreshTokenFamily(ctx context.Context, familyID id.SessionFamilyID, reason string) error
+ MarkRefreshTokenReplayed(ctx context.Context, tokenHash string) (firstReplay bool, err error)
}
```
@@ -68,10 +92,14 @@ type Store interface {
```go
type Store interface {
- CreateAccount(ctx context.Context, a *Account) error
- GetAccountByProvider(ctx context.Context, provider, providerID string) (*Account, error)
- GetAccountsByUserID(ctx context.Context, userID id.UserID) ([]*Account, error)
- DeleteAccount(ctx context.Context, accountID id.AccountID) error
+ CreateVerification(ctx context.Context, v *Verification) error
+ GetVerification(ctx context.Context, token string) (*Verification, error)
+ ConsumeVerification(ctx context.Context, token string) error
+ GetActiveEmailVerification(ctx context.Context, userID id.UserID) (*Verification, error)
+ UpdateVerification(ctx context.Context, v *Verification) error
+ CreatePasswordReset(ctx context.Context, pr *PasswordReset) error
+ GetPasswordReset(ctx context.Context, token string) (*PasswordReset, error)
+ ConsumePasswordReset(ctx context.Context, token string) error
}
```
@@ -79,17 +107,37 @@ type Store interface {
```go
type Store interface {
- CreateOrganization(ctx context.Context, o *Organization) error
- GetOrganization(ctx context.Context, orgID id.OrgID) (*Organization, error)
- UpdateOrganization(ctx context.Context, o *Organization) error
- DeleteOrganization(ctx context.Context, orgID id.OrgID) error
- ListOrganizations(ctx context.Context, filter *ListFilter) ([]*Organization, int64, error)
- ListUserOrganizations(ctx context.Context, userID id.UserID) ([]*Organization, error)
- AddMember(ctx context.Context, m *Member) error
- GetMember(ctx context.Context, memberID id.MemberID) (*Member, error)
- UpdateMember(ctx context.Context, m *Member) error
- RemoveMember(ctx context.Context, memberID id.MemberID) error
- ListMembers(ctx context.Context, orgID id.OrgID) ([]*Member, error)
+ CreateOrganization(ctx context.Context, o *Organization) error
+ GetOrganization(ctx context.Context, orgID id.OrgID) (*Organization, error)
+ GetOrganizationBySlug(ctx context.Context, appID id.AppID, slug string) (*Organization, error)
+ UpdateOrganization(ctx context.Context, o *Organization) error
+ DeleteOrganization(ctx context.Context, orgID id.OrgID) error
+ ListOrganizations(ctx context.Context, appID id.AppID) ([]*Organization, error)
+ ListUserOrganizations(ctx context.Context, userID id.UserID) ([]*Organization, error)
+ ListOrganizationsPage(ctx context.Context, appID id.AppID, opts page.Opts) (page.Page[*Organization], error)
+ ListUserOrganizationsPage(ctx context.Context, userID id.UserID, opts page.Opts) (page.Page[*Organization], error)
+ CreateMember(ctx context.Context, m *Member) error
+ GetMember(ctx context.Context, memberID id.MemberID) (*Member, error)
+ GetMemberByUserAndOrg(ctx context.Context, userID id.UserID, orgID id.OrgID) (*Member, error)
+ UpdateMember(ctx context.Context, m *Member) error
+ DeleteMember(ctx context.Context, memberID id.MemberID) error
+ ListMembers(ctx context.Context, orgID id.OrgID) ([]*Member, error)
+ ListMembersPage(ctx context.Context, orgID id.OrgID, opts page.Opts) (page.Page[*Member], error)
+ CreateInvitation(ctx context.Context, inv *Invitation) error
+ GetInvitation(ctx context.Context, invID id.InvitationID) (*Invitation, error)
+ GetInvitationByToken(ctx context.Context, token string) (*Invitation, error)
+ UpdateInvitation(ctx context.Context, inv *Invitation) error
+ DeleteInvitation(ctx context.Context, invID id.InvitationID) error
+ ListInvitations(ctx context.Context, orgID id.OrgID) ([]*Invitation, error)
+ ListInvitationsPage(ctx context.Context, orgID id.OrgID, opts page.Opts) (page.Page[*Invitation], error)
+ CreateTeam(ctx context.Context, t *Team) error
+ GetTeam(ctx context.Context, teamID id.TeamID) (*Team, error)
+ UpdateTeam(ctx context.Context, t *Team) error
+ DeleteTeam(ctx context.Context, teamID id.TeamID) error
+ ListTeams(ctx context.Context, orgID id.OrgID) ([]*Team, error)
+ ListTeamsPage(ctx context.Context, orgID id.OrgID, opts page.Opts) (page.Page[*Team], error)
+ WithTx(ctx context.Context, fn func(tx Store) error) error
+ DeleteOrganizationCascade(ctx context.Context, orgID id.OrgID) error
}
```
@@ -105,23 +153,19 @@ This causes a compile error if any required method is missing, which is much fas
## Migration handling
-The `Migrate` method accepts optional extra migration groups. These come from plugins that define their own tables. Your store must run both its own core migrations and any extra groups passed in.
+The `Migrate` method receives extra migration groups from plugins that define their own tables (the engine collects them with the driver name you pass to `WithDriverName`). A SQL store should run its own migrations and those groups. A store with no schema can do what `store/memory` does and return nil.
```go
+// For a store built on Grove, do what store/postgres does: run your core
+// group and the plugins' groups through one orchestrator.
func (s *MyStore) Migrate(ctx context.Context, extraGroups ...*migrate.Group) error {
- // Run core migrations first
- if err := s.runCoreMigrations(ctx); err != nil {
+ executor, err := migrate.NewExecutorFor(s.driver)
+ if err != nil {
return err
}
-
- // Run plugin migration groups
- for _, group := range extraGroups {
- if err := group.Run(ctx, s.db); err != nil {
- return fmt.Errorf("plugin migration: %w", err)
- }
- }
-
- return nil
+ groups := append([]*migrate.Group{myCoreMigrations}, extraGroups...)
+ _, err = migrate.NewOrchestrator(executor, groups...).Migrate(ctx)
+ return err
}
```
@@ -265,6 +309,7 @@ Pass your custom store when building the engine:
```go
eng, err := authsome.NewEngine(
+ // plus WithChronicle and WithWarden, which every engine needs (see Getting Started)
authsome.WithStore(dynamostore.New(client, "authsome")),
)
```
diff --git a/docs/content/docs/guides/forge-extension.mdx b/docs/content/docs/guides/forge-extension.mdx
index 49c9c7fb..b786003f 100644
--- a/docs/content/docs/guides/forge-extension.mdx
+++ b/docs/content/docs/guides/forge-extension.mdx
@@ -1,45 +1,61 @@
---
title: Forge Extension
-description: Mount Authsome into a Forge application as a first-class extension with automatic dependency discovery and route registration.
+description: Mount Authsome into a Forge application as an extension with bridge discovery and route registration.
---
-Authsome ships a ready-made Forge extension in the `extension` package. It wires the engine, HTTP API, middleware, and lifecycle management into Forge's extension system with automatic discovery of optional forgery bridges.
+The `github.com/xraph/authsome/extension` package registers Authsome as a [Forge](https://github.com/xraph/forge) extension. It reads `extensions.authsome` (or a top-level `authsome` key), builds a store from the container's `*grove.DB`, mounts the API under `/authsome`, runs the bootstrap and adds the auth middleware to your app.
## Installation
-```go
-import "github.com/xraph/authsome/extension"
+```bash
+go get github.com/xraph/authsome@latest github.com/xraph/chronicle@latest \
+ github.com/xraph/warden@latest github.com/xraph/grove/extension@latest
```
## Registering the extension
```go
-package main
-
import (
- "github.com/xraph/forge"
- "github.com/xraph/authsome/extension"
+ "github.com/xraph/forge"
+ grove "github.com/xraph/grove/extension"
+
+ authsome "github.com/xraph/authsome/extension"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/password"
+ chronicle "github.com/xraph/chronicle/extension"
+ warden "github.com/xraph/warden/extension"
)
-func main() {
- app := forge.New()
+app := forge.NewApp(forge.AppConfig{
+ Name: "acme-api",
+ Extensions: []forge.Extension{
+ grove.New(), // extensions.grove: driver and dsn
+ chronicle.New(), // audit trail, required by authsome
+ warden.New(), // authorization, required by authsome
+ authsome.New(
+ authsome.WithPlugins(
+ password.New(),
+ mfa.New(mfa.Config{Issuer: "Acme"}),
+ ),
+ ),
+ },
+})
+```
- authExt := extension.New(
- extension.WithGroveDatabase(""), // resolve default grove.DB from DI
- )
+Order matters. Authsome looks for `chronicle.Emitter` and `*warden.Engine` in the container during `Register` and refuses to start without them, so list Chronicle and Warden first. Grove has to come first too, because the store is built from its `*grove.DB`.
- app.RegisterExtension(authExt)
- app.Run()
-}
-```
+The engine also needs a token encryption key: set `AUTHSOME_TOKEN_ENCRYPTION_KEY` to 64 hex characters (or `AUTHSOME_TOKEN_ENCRYPTION_KEYS` as `kid:hex` pairs when you rotate), or pass an encryptor with `authsome.WithEngineOption(authsomecore.WithTokenEncryptor(enc))`, where `authsomecore` is the root `github.com/xraph/authsome` package.
-Or with an explicit store:
+To use a store you build yourself (in an app without the Grove extension, see [Grove database](#grove-database)):
```go
-import pgstore "github.com/xraph/authsome/store/postgres"
+import (
+ authsomecore "github.com/xraph/authsome"
+ pgstore "github.com/xraph/authsome/store/postgres"
+)
-authExt := extension.New(
- extension.WithEngineOption(authsome.WithStore(pgstore.New(db))),
+authExt := authsome.New(
+ authsome.WithEngineOption(authsomecore.WithStore(pgstore.New(db))),
)
```
@@ -47,205 +63,189 @@ authExt := extension.New(
| Lifecycle event | Behaviour |
|----------------|-----------|
-| `Register` | Loads configuration, auto-discovers bridges from DI, builds the engine, registers it in the container |
-| `Start` | Runs `store.Migrate` (unless disabled), initializes plugins, auto-registers strategies |
-| `RegisterRoutes` | Mounts all Authsome HTTP endpoints on the Forge router |
-| `Middlewares` | Returns the auth middleware for global application to all routes |
-| `Stop` | Calls `engine.Stop` -- emits `OnShutdown` to all plugins |
+| `Register` | Loads configuration, resolves Chronicle and Warden (required) and the optional bridges from DI, builds the store and engine, provides `*authsome.Engine`, mounts the routes |
+| `Start` | Starts the engine: migrations (unless disabled), bootstrap, plugin `OnInit` |
+| `Middlewares` | Returns the auth middleware, which Forge applies to every route |
+| `Stop` | Calls `engine.Stop`, which emits `OnShutdown` to every plugin |
## Extension options
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `WithConfig(cfg)` | `Config` | defaults | Full config struct |
-| `WithPlugin(p)` | `plugin.Plugin` | -- | Register a plugin (repeatable) |
-| `WithPlugins(ps...)` | `...plugin.Plugin` | -- | Register multiple plugins |
-| `WithEngineOption(opt)` | `authsome.Option` | -- | Pass engine option directly |
-| `WithEngineOptions(opts...)` | `...authsome.Option` | -- | Pass multiple engine options |
-| `WithDisableRoutes()` | -- | `false` | Skip HTTP route registration |
-| `WithDisableMigrate()` | -- | `false` | Skip migrations on Start |
-| `WithBasePath(path)` | `string` | `"/v1/auth"` | URL prefix for all auth routes |
-| `WithGroveDatabase(name)` | `string` | `""` | Name of the grove.DB to resolve from DI |
-| `WithRequireConfig(b)` | `bool` | `false` | Require config in YAML files |
-| `WithLogger(l)` | `log.Logger` | Forge logger | Custom logger |
+| Option | Default | Description |
+|--------|---------|-------------|
+| `WithConfig(cfg)` | `DefaultConfig()` | The whole config struct |
+| `WithPlugin(p)`, `WithPlugins(ps...)` | none | Register plugins |
+| `WithEngineOption(opt)`, `WithEngineOptions(opts...)` | none | Pass root `authsome.Option` values to the engine |
+| `WithBasePath(path)` | `"/authsome"` | URL prefix for the routes (the API itself is under `/v1`) |
+| `WithDisableRoutes()` | off | Skip route registration |
+| `WithDisableMigrate()` | off | Skip migrations on start |
+| `WithGroveDatabase(name)` | the default `*grove.DB` | A named Grove database |
+| `WithRequireConfig(b)` | `false` | Fail when there's no YAML config |
+| `WithLogger(l)` | the app's logger | A go-utils `log.Logger` |
+| `WithClientMode(portalURL)`, `WithServiceAPIKey(key)`, `WithServiceAppID(id)`, `WithClientAutoRefresh(on, threshold)` | off | Client mode, below |
-## Auto-discovered bridges
+## Bridges
-When running inside a Forge app, the extension automatically discovers optional bridges from the DI container. You do not need to configure these manually -- they are resolved if the corresponding extension is registered.
+Chronicle and Warden are required. Everything else is optional: when the matching service is in the container, the extension wires it in as a bridge, and otherwise it uses a no-op.
| Bridge | Discovered from | Purpose |
|--------|----------------|---------|
-| Chronicle | `chronicle.Emitter` | Audit trail logging |
-| Warden | `*warden.Engine` | RBAC/ReBAC/ABAC authorization |
+| Chronicle (required) | `chronicle.Emitter` | Audit trail |
+| Warden (required) | `*warden.Engine` | RBAC authorization |
| Keysmith | `*keysmith.Engine` | API key management |
-| Relay | `*relay.Relay` | Webhook event delivery |
-| Herald | `*herald.Herald` | Multi-channel notifications |
-| Dispatch | `*dispatch.Engine` | Background job queue |
+| Relay | `*relay.Relay` | Webhook delivery |
+| Herald | `*herald.Herald` | Notifications |
+| Dispatch | `*dispatch.Engine` | Background jobs |
| Ledger | `*ledger.Ledger` | Billing and metering |
-| Vault | `*vault.Vault` | Secrets and feature flags |
-| Grove DB | `*grove.DB` | Database (PostgreSQL, SQLite, MongoDB) |
-
-If a bridge is not found in the container, a no-op implementation is used automatically.
+| Grove DB | `*grove.DB` | The store (PostgreSQL, SQLite or MongoDB) |
-## File-based configuration (YAML)
+## YAML configuration
-The extension loads configuration from YAML config files. It looks for config under these keys (in order):
-
-1. `extensions.authsome` -- standard Forge extension config namespace
-2. `authsome` -- top-level shorthand
-
-### Example YAML config
+The extension reads `extensions.authsome`, then a top-level `authsome` key:
```yaml
-# forge.yaml
extensions:
authsome:
- base_path: "/v1/auth"
- disable_routes: false
- disable_migrate: false
- grove_database: ""
- debug: false
-
+ base_path: /authsome
+ grove_database: primary # optional; the default *grove.DB otherwise
session:
- token_ttl: "1h"
- refresh_token_ttl: "720h"
- max_active_sessions: 0
+ token_ttl: 1h
+ refresh_token_ttl: 720h
+ max_active_sessions: 5 # 0 means unlimited
rotate_refresh_token: true
-
password:
min_length: 12
require_uppercase: true
require_lowercase: true
require_digit: true
- bcrypt_cost: 12
- algorithm: "bcrypt"
-
+ algorithm: argon2id # bcrypt by default; old hashes are rehashed on sign-in
+ check_breached: true
rate_limit:
- enabled: true
+ enabled: true # on unless explicitly false
signin_limit: 5
signup_limit: 3
window_seconds: 60
-
lockout:
- enabled: true
+ enabled: true # on unless explicitly false
max_attempts: 5
lockout_duration_seconds: 900
reset_after_seconds: 3600
-
mailer:
- provider: "resend"
+ provider: resend # resend or smtp; nothing is sent otherwise
resend:
api_key: "${RESEND_API_KEY}"
- from: "noreply@example.com"
+ from: noreply@example.com
+ bootstrap:
+ app_name: Acme
+ initial_owners: [founder@example.com]
```
-### Merge behaviour
-
-File-based configuration is merged with programmatic options. Programmatic boolean flags (`DisableRoutes`, `DisableMigrate`, `Debug`) always win when set to `true`. For other fields, YAML values take precedence, then programmatic values, then defaults.
+The `Config` struct in `extension/config.go` also covers `retention`, `csrf`, `webhooks`, per-app session settings under `apps`, bootstrap environments and `warden_dir` for your own role files, `dashboard`, and the client-mode keys.
-## Accessing the engine from other extensions
+### Merge behaviour
-After `Register` is called by Forge, `authExt.Engine()` returns the fully initialized engine:
+YAML is merged with your programmatic options. Boolean flags you set in code (`DisableRoutes`, `DisableMigrate`, `Debug`) win when they're `true`. For other fields YAML takes precedence, then your options, then the defaults.
-```go
-eng := authExt.Engine()
-// Use eng.Store(), eng.Plugins(), etc. from another extension.
-```
+## Accessing the engine
-Or resolve the engine from the DI container:
+After Forge has called `Register`, `authExt.Engine()` returns the engine. It's also in the container:
```go
import (
- "github.com/xraph/vessel"
- authsome "github.com/xraph/authsome"
+ "github.com/xraph/vessel"
+
+ authsomecore "github.com/xraph/authsome"
)
-eng, err := vessel.Inject[*authsome.Engine](app.Container())
+eng, err := vessel.Inject[*authsomecore.Engine](app.Container())
```
## Adding plugins
-Register plugins that provide additional authentication methods:
-
```go
import (
- "github.com/xraph/authsome/extension"
- "github.com/xraph/authsome/plugins/social"
- "github.com/xraph/authsome/plugins/mfa"
- "github.com/xraph/authsome/plugins/passkey"
- "github.com/xraph/authsome/plugins/magiclink"
- "github.com/xraph/authsome/plugins/phone"
+ "github.com/xraph/authsome/plugins/magiclink"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/passkey"
+ "github.com/xraph/authsome/plugins/password"
+ "github.com/xraph/authsome/plugins/phone"
+ "github.com/xraph/authsome/plugins/social"
)
-authExt := extension.New(
- extension.WithGroveDatabase(""),
- extension.WithPlugins(
- social.New(
- social.WithGoogle(googleID, googleSecret),
- social.WithGitHub(ghID, ghSecret),
- ),
- mfa.New(),
- passkey.New(passkey.Config{
- RPName: "My App",
- RPID: "example.com",
- Origin: "https://example.com",
- }),
- magiclink.New(),
- phone.New(),
- ),
+authExt := authsome.New(
+ authsome.WithPlugins(
+ password.New(),
+ social.New(social.Config{
+ // Callbacks default to {Domain}/v1/social/{provider}/callback.
+ Domain: "https://app.example.com/authsome",
+ Providers: []social.Provider{
+ social.NewGoogleProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+ }),
+ social.NewGitHubProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GITHUB_CLIENT_ID"),
+ ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
+ }),
+ },
+ }),
+ mfa.New(mfa.Config{Issuer: "Acme"}),
+ passkey.New(passkey.Config{
+ RPDisplayName: "My App",
+ RPID: "example.com",
+ RPOrigins: []string{"https://example.com"},
+ }),
+ magiclink.New(),
+ phone.New(),
+ ),
)
```
## Auth middleware
-The extension automatically registers global authentication middleware via `Middlewares()`. This middleware:
+The extension's `Middlewares()` returns the auth middleware, and Forge applies it to every route. It:
-1. Reads the `Authorization: Bearer ` header
-2. Validates the token (opaque lookup or JWT validation)
-3. Falls back to the strategy registry (API keys, etc.) when session resolution fails
-4. Sets the authenticated user and session on the request context
+1. Reads the `Authorization: Bearer ` header (or the session cookie)
+2. Resolves the session (an opaque lookup, or JWT validation for apps on the JWT format)
+3. Falls back to the strategy registry (API keys and so on) when session resolution fails
+4. Puts the user and session on the request context
-Other extensions can access the authenticated user via the Forge scope:
+Read them in your handlers with the `middleware` package:
```go
-import "github.com/xraph/forge"
-
-func handler(w http.ResponseWriter, r *http.Request) {
- scope := forge.ScopeFromContext(r.Context())
- userID := scope.UserID()
- orgID := scope.OrgID()
+import "github.com/xraph/authsome/middleware"
+
+func handler(ctx forge.Context) error {
+ user, ok := middleware.UserFrom(ctx.Context())
+ if !ok {
+ return forge.Unauthorized("sign in first")
+ }
+ orgID, _ := middleware.OrgIDFrom(ctx.Context())
+ // ...
}
```
-## Grove database integration
+The middleware doesn't require authentication on its own. Routes that need a user check for one, as above.
-### Using the default grove database
+## Grove database
-```go
-ext := extension.New(
- extension.WithGroveDatabase(""),
-)
-```
+By default the extension uses the default `*grove.DB` in the container. Name a different one with `WithGroveDatabase("authsome")` or `grove_database: authsome`. The store backend is picked from the Grove driver: PostgreSQL, SQLite or MongoDB.
+
+A store passed with `WithEngineOption(authsomecore.WithStore(s))` is applied before the Grove one, so whenever a `*grove.DB` is in the container the Grove store wins. Pass your own store only in apps that don't register Grove.
-### Using a named grove database
+## Client mode
+
+A service that should trust an Authsome deployment elsewhere, rather than run its own, uses client mode:
```go
-ext := extension.New(
- extension.WithGroveDatabase("authsome"),
+authExt := authsome.New(
+ authsome.WithClientMode("https://auth.example.com/authsome"),
+ authsome.WithServiceAPIKey(os.Getenv("AUTHSOME_SERVICE_KEY")),
+ authsome.WithServiceAppID("aapp_..."),
)
```
-### Store resolution order
-
-1. **Explicit store** -- if `WithEngineOption(authsome.WithStore(s))` was called, it is used directly.
-2. **Grove database** -- if `WithGroveDatabase(name)` was called, the named or default `grove.DB` is resolved from DI.
-3. **Auto-discovery** -- if neither is configured, the extension attempts to resolve a default `grove.DB` from the container.
+In client mode the extension validates incoming tokens against the remote deployment instead of building a local engine.
## Dashboard integration
-When the Forge Dashboard extension is present, Authsome automatically registers as the dashboard's authentication provider:
-
-- Provides sign-in and sign-up pages for the dashboard
-- Handles session checking and user resolution
-- Contributes "Profile" and "Security" items to the user dropdown menu
-- Renders user management pages in the dashboard
+When the Forge dashboard extension is present, Authsome registers as its authentication provider and contributes its pages through the dashboard contract.
diff --git a/docs/content/docs/guides/full-example.mdx b/docs/content/docs/guides/full-example.mdx
index b7e19e00..3a4312ea 100644
--- a/docs/content/docs/guides/full-example.mdx
+++ b/docs/content/docs/guides/full-example.mdx
@@ -3,15 +3,16 @@ title: Full Example
description: Complete standalone Authsome server with PostgreSQL, all plugins, and bridge setup.
---
-This guide builds a complete, production-ready Authsome authentication server from scratch. By the end you will have a standalone Go server with email/password auth, social login, MFA, organizations, and webhook delivery.
+This guide builds a standalone Authsome server on PostgreSQL with email and password sign-in, social login, MFA and organizations.
## 1. Project setup
```bash
mkdir authsome-server && cd authsome-server
go mod init myapp/authsome-server
-go get github.com/xraph/authsome
-go get github.com/xraph/grove
+go get github.com/xraph/authsome@latest github.com/xraph/chronicle@latest \
+ github.com/xraph/warden@latest github.com/xraph/grove@latest \
+ github.com/xraph/grove/drivers/pgdriver@latest
```
## 2. Docker Compose for dependencies
@@ -58,133 +59,168 @@ docker compose up -d
package main
import (
- "context"
- "fmt"
- "log"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- authsome "github.com/xraph/authsome"
- "github.com/xraph/authsome/api"
- "github.com/xraph/authsome/bridge"
- "github.com/xraph/authsome/bridge/maileradapter"
- "github.com/xraph/authsome/lockout"
- "github.com/xraph/authsome/ratelimit"
- pgstore "github.com/xraph/authsome/store/postgres"
-
- "github.com/xraph/grove"
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/xraph/chronicle"
+ chstore "github.com/xraph/chronicle/store"
+ chmemory "github.com/xraph/chronicle/store/memory"
+ "github.com/xraph/forge"
+ "github.com/xraph/grove"
+ "github.com/xraph/grove/drivers/pgdriver"
+ "github.com/xraph/warden"
+ wmemory "github.com/xraph/warden/store/memory"
+
+ "github.com/xraph/authsome"
+ "github.com/xraph/authsome/api"
+ "github.com/xraph/authsome/bridge"
+ "github.com/xraph/authsome/bridge/chronicleadapter"
+ "github.com/xraph/authsome/bridge/maileradapter"
+ "github.com/xraph/authsome/middleware"
+ "github.com/xraph/authsome/plugins/mfa"
+ "github.com/xraph/authsome/plugins/organization"
+ "github.com/xraph/authsome/plugins/password"
+ "github.com/xraph/authsome/plugins/social"
+ pgstore "github.com/xraph/authsome/store/postgres"
)
func main() {
- ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
- defer cancel()
-
- // ── Database ──
- db, err := grove.Open("pg", os.Getenv("DATABASE_URL"))
- if err != nil {
- log.Fatal("open db:", err)
- }
- defer db.Close()
-
- store := pgstore.New(db)
-
- // ── Mailer (Mailpit for dev, Resend for production) ──
- var mailer bridge.Mailer
- if apiKey := os.Getenv("RESEND_API_KEY"); apiKey != "" {
- mailer = maileradapter.NewResendMailer(apiKey, os.Getenv("MAIL_FROM"))
- } else {
- mailer = maileradapter.NewSMTPMailer(
- "localhost", 1025, "", "", "noreply@example.com",
- )
- }
-
- // ── Build Engine ──
- eng, err := authsome.NewEngine(
- authsome.WithStore(store),
- authsome.WithMailer(mailer),
- authsome.WithSMSSender(bridge.NewNoopSMSSender()),
- authsome.WithConfig(authsome.Config{
- BasePath: "/v1/auth",
- Session: authsome.SessionConfig{
- TokenTTL: 1 * time.Hour,
- RefreshTokenTTL: 30 * 24 * time.Hour,
- },
- Password: authsome.PasswordConfig{
- MinLength: 12,
- RequireUppercase: true,
- RequireLowercase: true,
- RequireDigit: true,
- BcryptCost: 12,
- },
- RateLimit: authsome.RateLimitConfig{
- Enabled: true,
- SignInLimit: 5,
- SignUpLimit: 3,
- WindowSeconds: 60,
- },
- Lockout: authsome.LockoutConfig{
- Enabled: true,
- MaxAttempts: 5,
- LockoutDurationSeconds: 900,
- },
- }),
- authsome.WithRateLimiter(ratelimit.NewMemoryLimiter()),
- authsome.WithLockoutTracker(lockout.NewMemoryTracker()),
- authsome.WithDriverName("pg"),
- )
- if err != nil {
- log.Fatal("create engine:", err)
- }
-
- // ── Start (runs migrations) ──
- if err := eng.Start(ctx); err != nil {
- log.Fatal("start engine:", err)
- }
- defer eng.Stop(context.Background())
-
- // ── HTTP API ──
- apiHandler := api.New(eng)
- mux := http.NewServeMux()
- mux.Handle("/", apiHandler.Handler())
-
- // Health check
- mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
- if err := eng.Health(r.Context()); err != nil {
- http.Error(w, "unhealthy", http.StatusServiceUnavailable)
- return
- }
- w.WriteHeader(http.StatusOK)
- fmt.Fprint(w, "ok")
- })
-
- addr := ":" + envOr("PORT", "8080")
- srv := &http.Server{Addr: addr, Handler: mux}
-
- go func() {
- log.Printf("authsome listening on %s", addr)
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatal("serve:", err)
- }
- }()
-
- <-ctx.Done()
- log.Println("shutting down...")
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer shutdownCancel()
- srv.Shutdown(shutdownCtx)
+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ // Database.
+ pg := pgdriver.New()
+ if err := pg.Open(ctx, os.Getenv("DATABASE_URL")); err != nil {
+ log.Fatal("open db: ", err)
+ }
+ db, err := grove.Open(pg)
+ if err != nil {
+ log.Fatal("open db: ", err)
+ }
+ defer db.Close()
+
+ // Audit trail and authorization (both required). In memory here;
+ // give them persistent stores in production.
+ trail, err := chronicle.New(chronicle.WithStore(chstore.NewAdapter(chmemory.New())))
+ if err != nil {
+ log.Fatal(err)
+ }
+ authz, err := warden.NewEngine(warden.WithStore(wmemory.New()))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Mailer: Resend in production, Mailpit over SMTP in development.
+ // The core engine sends no mail itself; the email plugin and your own
+ // code send through this bridge (eng.Mailer()).
+ var mailer bridge.Mailer
+ if apiKey := os.Getenv("RESEND_API_KEY"); apiKey != "" {
+ mailer = maileradapter.NewResendMailer(apiKey, os.Getenv("MAIL_FROM"))
+ } else {
+ mailer = maileradapter.NewSMTPMailer("localhost", "1025", "", "", "noreply@example.com")
+ }
+
+ cfg := authsome.DefaultConfig()
+ cfg.Session.TokenTTL = time.Hour
+ cfg.Session.RefreshTokenTTL = 30 * 24 * time.Hour
+ cfg.Password.MinLength = 12
+ cfg.RateLimit.SignInLimit = 5
+ cfg.RateLimit.SignUpLimit = 3
+ cfg.Lockout.MaxAttempts = 5
+ cfg.Lockout.LockoutDurationSeconds = 900
+
+ engine, err := authsome.NewEngine(
+ authsome.WithConfig(cfg),
+ authsome.WithStore(pgstore.New(db)),
+ authsome.WithDriverName("pg"), // lets plugins pick their PostgreSQL migrations
+ authsome.WithDB(db), // lets plugins such as mfa and social store their data in PostgreSQL
+ authsome.WithChronicle(chronicleadapter.New(trail)),
+ authsome.WithWarden(authz),
+ authsome.WithMailer(mailer),
+ authsome.WithPlugin(password.New()),
+ authsome.WithPlugin(mfa.New(mfa.Config{Issuer: "Acme"})),
+ authsome.WithPlugin(organization.New()),
+ authsome.WithPlugin(social.New(social.Config{
+ Domain: "http://localhost:8080",
+ Providers: []social.Provider{
+ social.NewGoogleProvider(social.ProviderConfig{
+ ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+ }),
+ },
+ })),
+ authsome.WithBootstrap(authsome.WithBootstrapAppName("Acme")),
+ )
+ if err != nil {
+ log.Fatal("create engine: ", err)
+ }
+
+ // Start runs the migrations for the core and every plugin.
+ if err := engine.Start(ctx); err != nil {
+ log.Fatal("start engine: ", err)
+ }
+ defer engine.Stop(context.Background())
+
+ router := forge.NewRouter()
+ router.Use(middleware.AuthMiddleware(engine.ResolveSessionByToken, engine.ResolveUser, nil))
+ if err := api.New(engine).RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+ }
+ // Plugins mount their own routes (/v1/orgs, /v1/mfa, /v1/social and so on).
+ for _, rp := range engine.Plugins().RouteProviders() {
+ if err := rp.RegisterRoutes(router); err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("/", router.Handler())
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ if err := engine.Health(r.Context()); err != nil {
+ http.Error(w, "unhealthy", http.StatusServiceUnavailable)
+ return
+ }
+ fmt.Fprint(w, "ok")
+ })
+
+ addr := ":" + envOr("PORT", "8080")
+ srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
+
+ go func() {
+ log.Printf("authsome listening on %s, app_id=%s", addr, engine.PlatformAppID())
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatal("serve: ", err)
+ }
+ }()
+
+ <-ctx.Done()
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer shutdownCancel()
+ _ = srv.Shutdown(shutdownCtx)
}
func envOr(key, fallback string) string {
- if v := os.Getenv(key); v != "" {
- return v
- }
- return fallback
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
}
```
+A few things to notice:
+
+- Chronicle and Warden are required. The engine refuses to start without them. They run in memory here, so the audit trail and role grants reset on restart; give them persistent stores before production.
+- `engine.Start` runs the migrations for the core and every registered plugin.
+- `WithBootstrap` creates the platform app on first start. The log line prints its ID, which clients send as `app_id` (or as an `X-Publishable-Key` header).
+- Rate limits and lockout are on by default. With no limiter or tracker passed, the engine keeps their counters in the store, so every instance sharing the database shares the limits.
+
## 4. Environment variables
```bash
@@ -192,7 +228,10 @@ func envOr(key, fallback string) string {
DATABASE_URL=postgres://authsome:secret@localhost:5432/authsome?sslmode=disable
PORT=8080
-# Production mailer (optional -- uses Mailpit in dev)
+# Required: 64 hex characters for token encryption
+AUTHSOME_TOKEN_ENCRYPTION_KEY=