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, a plugin-driven authentication and identity engine for Go with sessions, social login, passkeys and MFA

-

- Features · - Quick Start · - Plugins · - SDKs · - API · - Configuration -

+# 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. +[![Go 1.26+](https://img.shields.io/badge/Go-1.26%2B-00ADD8?logo=go&logoColor=white)](https://go.dev/dl/) [![CI](https://github.com/xraph/authsome/actions/workflows/ci.yml/badge.svg)](https://github.com/xraph/authsome/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/xraph/authsome.svg)](https://pkg.go.dev/github.com/xraph/authsome) [![GitHub Stars](https://img.shields.io/github/stars/xraph/authsome?style=flat)](https://github.com/xraph/authsome/stargazers) -

- Authsome Dashboard -

+[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 @@ + + Authsome + Authsome is a plugin-driven authentication and identity engine for Go: users, sessions, organizations and RBAC in the core, with passwords, social login, magic links, passkeys, MFA, SSO and more as plugins. + + + + + + + + + + + + + + A + + FORGERY / BUILT ON FORGE + Authsome + Authentication and identity for Go. + Sessions / Social login / Passkeys / MFA + + + + + + + + + + + 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..3e1016e9 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..e96c56c9 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..cd4b3a04 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..cee9d67b 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..dd233348 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..ab57ce40 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= + +# Production mailer (optional; Mailpit is used in development) # RESEND_API_KEY=re_xxxx # MAIL_FROM=noreply@yourdomain.com ``` @@ -200,30 +239,49 @@ PORT=8080 ## 5. Run the server ```bash -source .env -go run main.go +set -a; source .env; set +a +go run . ``` ## 6. Testing the server +Use the `app_id` from the startup log line. + ### Sign up ```bash -curl -X POST http://localhost:8080/v1/auth/signup \ +curl -X POST http://localhost:8080/v1/signup \ -H "Content-Type: application/json" \ -d '{ + "app_id": "aapp_...", "email": "jane@example.com", "password": "SecurePass1!", - "name": "Jane Doe" + "first_name": "Jane", + "last_name": "Doe" }' ``` -### Sign in +The response has `session_token`, `refresh_token`, `expires_at` and `user`. + +### Verify the email, then sign in + +Sign-in refuses an unverified address while `auth.require_email_verification` is on (the default). Sign-up issues a 6-digit verification code, but Authsome doesn't email it on its own: the `notification` plugin delivers it through a Herald bridge, and the mailer above isn't involved. Until you set that up, you have two options: + +- Deliver the code yourself. `engine.SendEmailVerification(ctx, user)` issues a fresh code and returns it; the client then posts `{"email": "jane@example.com", "code": "123456"}` to `POST /v1/verify-email`. +- Turn the check off while you develop: + + ```go + _ = engine.Settings().Set(ctx, "auth.require_email_verification", + json.RawMessage(`false`), settings.ScopeGlobal, "", "", "", "dev") + ``` + +Then sign in: ```bash -curl -X POST http://localhost:8080/v1/auth/signin \ +curl -X POST http://localhost:8080/v1/signin \ -H "Content-Type: application/json" \ -d '{ + "app_id": "aapp_...", "email": "jane@example.com", "password": "SecurePass1!" }' @@ -232,14 +290,14 @@ curl -X POST http://localhost:8080/v1/auth/signin \ ### Get current user ```bash -curl http://localhost:8080/v1/auth/me \ +curl http://localhost:8080/v1/me \ -H "Authorization: Bearer " ``` ### Refresh token ```bash -curl -X POST http://localhost:8080/v1/auth/refresh \ +curl -X POST http://localhost:8080/v1/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": ""}' ``` @@ -247,53 +305,55 @@ curl -X POST http://localhost:8080/v1/auth/refresh \ ### Sign out ```bash -curl -X POST http://localhost:8080/v1/auth/signout \ +curl -X POST http://localhost:8080/v1/signout \ -H "Authorization: Bearer " ``` ### List organizations +The `organization` plugin adds its routes under `/v1/orgs`: + ```bash -curl http://localhost:8080/v1/organizations \ +curl http://localhost:8080/v1/orgs \ -H "Authorization: Bearer " ``` ## 7. Adding plugins -Extend the engine with plugins for social login, MFA, passkeys, and more. Each plugin registers its own routes and migrations: +Each plugin registers its own routes and migrations. Add more with `authsome.WithPlugin`: ```go import ( - "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/magiclink" + "github.com/xraph/authsome/plugins/passkey" + "github.com/xraph/authsome/plugins/social" ) -eng, err := authsome.NewEngine( - authsome.WithStore(store), - authsome.WithMailer(mailer), - // Plugins - authsome.WithPlugin(social.New( - social.WithGoogle(os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET")), - social.WithGitHub(os.Getenv("GITHUB_CLIENT_ID"), os.Getenv("GITHUB_CLIENT_SECRET")), - )), - authsome.WithPlugin(mfa.New()), - authsome.WithPlugin(passkey.New(passkey.Config{ - RPName: "My App", - RPID: "example.com", - Origin: "https://example.com", - })), - authsome.WithPlugin(magiclink.New()), - // ... rest of config -) +authsome.WithPlugin(social.New(social.Config{ + Domain: "https://auth.example.com", + 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"), + }), + }, +})), +authsome.WithPlugin(passkey.New(passkey.Config{ + RPDisplayName: "My App", + RPID: "example.com", + RPOrigins: []string{"https://example.com"}, +})), +authsome.WithPlugin(magiclink.New()), ``` ## 8. Production considerations - Use a connection pooler (PgBouncer) for PostgreSQL in production -- Replace the in-memory rate limiter and lockout tracker with Redis-backed implementations +- Give Chronicle and Warden persistent stores - Set `RESEND_API_KEY` and `MAIL_FROM` for transactional email - Add TLS termination via a reverse proxy (nginx, Caddy, or a cloud load balancer) -- Set `SESSION_BIND_TO_IP=true` and `SESSION_BIND_TO_DEVICE=true` for stricter session security - For multi-service deployments, use the [Forge Extension](/docs/guides/forge-extension) instead diff --git a/docs/content/docs/guides/migration-guide.mdx b/docs/content/docs/guides/migration-guide.mdx index 0268f52c..f32a65f1 100644 --- a/docs/content/docs/guides/migration-guide.mdx +++ b/docs/content/docs/guides/migration-guide.mdx @@ -34,7 +34,7 @@ Firebase users map to Authsome as follows: | `displayName` | `user.name` | Direct mapping | | `photoUrl` | `user.avatar_url` | Direct mapping | | `emailVerified` | `user.email_verified` | Direct mapping | -| `passwordHash` | -- | Firebase uses scrypt; requires re-hashing or forced reset | +| `passwordHash` | none | Firebase uses scrypt; requires re-hashing or forced reset | | `providerUserInfo` | `account` records | One account per provider | | `mfaInfo` | MFA plugin enrollment | Requires MFA plugin setup | @@ -200,6 +200,7 @@ jwtFmt, _ := tokenformat.NewJWT(tokenformat.JWTConfig{ }) eng, _ := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithDefaultTokenFormat(jwtFmt), ) diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 0c4587fc..8b307b55 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -3,37 +3,37 @@ title: Introduction description: Production-grade authentication & identity platform for Go. --- -Authsome is a Go library that delivers complete authentication and identity management for production applications. It handles everything from password-based sign-in to enterprise SSO, multi-factor authentication, passkeys, social login, organizations, RBAC, webhook delivery, and dynamic sign-up forms — all behind a single `Engine` type you embed in your own application. +Authsome is a Go library that delivers complete authentication and identity management for production applications. It covers password sign-in, enterprise SSO, multi-factor authentication, passkeys, social login, organizations, RBAC, webhook delivery and dynamic sign-up forms, all behind one `Engine` type you embed in your own application. -Authsome is a **library, not a service.** You bring your own database, HTTP server, and process lifecycle. Authsome provides the authentication plumbing. +Authsome is a library, not a service. You bring the database, the HTTP server and the process lifecycle; Authsome brings the authentication plumbing. It does need two other xraph libraries at runtime: [Chronicle](https://github.com/xraph/chronicle) for the audit trail and [Warden](https://github.com/xraph/warden) for authorization. ## What it does | Capability | Details | |------------|---------| -| **14 plugins** | `password`, `magiclink`, `social`, `sso`, `passkey`, `mfa`, `phone`, `oauth2provider`, `organization`, `apikey`, `consent`, `email`, `notification`, `passkey` | -| **4 store backends** | `memory` (testing), `postgres`, `sqlite`, `mongo` | -| **40+ UI components** | Pre-built React components via `@authsome/ui` for sign-in, sign-up, MFA, org management, and more | -| **31 webhook events** | `auth.signin`, `user.created`, `org.member_added`, `mfa.enrolled`, `session.revoked`, and 26 more | -| **27 TypeID prefixes** | `ausr_`, `ases_`, `aapp_`, `aorg_`, `amem_`, `atm_`, `ainv_`, `adev_`, `awhk_`, `antf_`, `avrf_`, `apwr_`, `akey_`, `aoau_`, `apsk_`, `amfa_`, `aenv_`, `arol_`, `aprm_`, `arec_`, `asso_`, `acns_`, `afcf_`, `abrd_`, `ascf_`, `ao2c_`, `aaco_` | -| **Token formats** | Opaque (64-char hex, default) or JWT with OIDC claims, configurable per app | -| **Session management** | Token + refresh token pairs, rotation, IP/device binding, max active sessions | -| **Environments** | Isolated production, staging, and development environments per app | -| **RBAC** | Roles, permissions, role hierarchy with native Warden integration | -| **Dynamic forms** | Field-level validation and metadata capture on sign-up and other flows | -| **Bridges** | 11 integration adapters: Chronicle (audit), Relay (webhooks), Mailer, SMS, Herald (notifications), Warden (authz), Keysmith (API keys), Vault (secrets), Dispatch (jobs), Ledger (billing), Metrics | +| 27 plugins | Sign-in methods (`password`, `magiclink`, `social`, `sso`, `passkey`, `mfa`, `phone`), `oauth2provider`, `organization`, `apikey`, `scim`, `consent`, `retention`, risk signals (`riskengine`, `geoip`, `impossibletravel` and more) and others. See [Plugins](/docs/plugins/overview) | +| 4 store backends | `memory` (testing), `postgres`, `sqlite`, `mongo` | +| UI components | `@authsome/ui-react`, `-vue` and `-nextjs` on top of `@authsome/ui-core` and `@authsome/ui-components`, plus the `@authsome/client` SDK | +| Webhook events | Sign-in, user, organization, MFA and session events, delivered through the Relay bridge | +| TypeIDs | Prefixed, sortable IDs such as `ausr_` (users), `ases_` (sessions), `aapp_` (apps) and `aenv_` (environments) | +| Token formats | Opaque (64-char hex, default) or JWT with OIDC claims, configurable per app | +| Session management | Token + refresh token pairs, rotation, IP/device binding, max active sessions | +| Environments | Isolated production, staging, and development environments per app | +| RBAC | Roles, permissions, role hierarchy with native Warden integration | +| Dynamic forms | Field-level validation and metadata capture on sign-up and other flows | +| Bridges | Chronicle (audit, required), Warden (authorization, required), Relay (webhooks), Mailer, SMS, Herald (notifications), Keysmith (API keys), Vault (secrets), Dispatch (jobs), Ledger (billing), Metrics | ## Design philosophy -**Library, not service.** Authsome is a set of Go packages you import. You control `main`, the database connection, and the HTTP server. There is no sidecar to run. +Authsome is a set of Go packages you import. You control `main`, the database connection and the HTTP server. There's no sidecar to run. -**Interfaces over implementations.** Every bridge is an interface. Swap any external dependency with a single type change. +Bridges are interfaces, so you can swap an external dependency for your own implementation. -**Tenant-scoped by design.** Every entity is scoped to an `AppID`. Cross-app data access is structurally impossible at the store layer. +Every entity is scoped to an `AppID`, and store lookups take the app (and usually the environment) as an argument. -**Plugin system.** Authentication methods are plugins. You opt in to exactly the methods your application needs. Password, magic links, social login, and SSO are all independent plugins that register routes and strategies when added to the engine. +Authentication methods are plugins, so you opt in to the ones you need. Password, magic links, social login and SSO each register their own routes and strategies when you add them. -**TypeID everywhere.** All entities use type-prefixed, K-sortable, UUIDv7-based identifiers. Passing a `ausr_` ID where an `ases_` ID is expected fails at parse time. +Entities use type-prefixed, sortable TypeIDs. Parsing an `ausr_` ID as a session ID fails. ## Quick look @@ -45,7 +45,17 @@ import ( "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" ) @@ -53,47 +63,43 @@ import ( func main() { ctx := context.Background() - // 1. Create an in-memory store (swap for postgres or sqlite in production). - memStore := memory.New() + 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) + } - // 2. Build the engine with the password plugin. - eng, err := authsome.New( - authsome.WithStore(memStore), + eng, err := authsome.NewEngine( + authsome.WithStore(memory.New()), // swap for postgres or sqlite in production + authsome.WithChronicle(chronicleadapter.New(trail)), + authsome.WithWarden(authz), + authsome.WithDisableMigrate(), 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: 10, - RequireUppercase: true, - RequireDigit: true, - }, - }), + authsome.WithBootstrap(authsome.WithBootstrapAppName("Acme")), ) if err != nil { log.Fatal(err) } - defer eng.Stop(ctx) - - // 3. Start the engine (runs migrations, initializes plugins). if err := eng.Start(ctx); err != nil { log.Fatal(err) } + defer eng.Stop(ctx) - // 4. Mount the auto-registered auth routes on your HTTP mux. - mux := http.NewServeMux() - eng.RegisterRoutes(mux) + router := forge.NewRouter() + router.Use(middleware.AuthMiddleware(eng.ResolveSessionByToken, eng.ResolveUser, nil)) + if err := api.New(eng).RegisterRoutes(router); err != nil { + log.Fatal(err) + } - log.Println("listening on :8080") - log.Fatal(http.ListenAndServe(":8080", mux)) + log.Printf("listening on :8080, app_id=%s", eng.PlatformAppID()) + log.Fatal(http.ListenAndServe(":8080", router.Handler())) } ``` -With only this setup you get `POST /v1/auth/signup`, `POST /v1/auth/signin`, `POST /v1/auth/signout`, `POST /v1/auth/refresh`, `GET /v1/auth/me`, and the full password reset flow. +Run it with `AUTHSOME_TOKEN_ENCRYPTION_KEY` set to 64 hex characters (`openssl rand -hex 32`). That gives you `POST /v1/signup`, `POST /v1/signin`, `POST /v1/signout`, `POST /v1/refresh`, `GET /v1/me` and the password reset flow. ## Where to go next @@ -101,5 +107,5 @@ With only this setup you get `POST /v1/auth/signup`, `POST /v1/auth/signin`, `PO - + diff --git a/docs/content/docs/organizations/invitations.mdx b/docs/content/docs/organizations/invitations.mdx index 55aac506..fd465478 100644 --- a/docs/content/docs/organizations/invitations.mdx +++ b/docs/content/docs/organizations/invitations.mdx @@ -1,240 +1,78 @@ --- title: Invitations -description: Email-based invitation flow for adding members to organizations, with token lifecycle and status tracking. +description: Inviting people to an organization by email, and what you have to deliver yourself. --- -Invitations let you add users to an organization through an email-based ceremony. The inviting user sends an invitation to an email address; the recipient receives a link containing a time-limited token, follows it to accept or decline, and (if accepted) is added to the organization as a member. Invitations support pending, accepted, expired, and declined statuses. +An invitation offers an email address a role in an organization. Whoever owns that address can accept it and becomes a member. ## The Invitation model ```go -import "github.com/xraph/authsome/org" +import "github.com/xraph/authsome/organization" type Invitation struct { - ID id.ID `json:"id"` // ainv_ prefix - OrgID id.ID `json:"org_id"` - AppID string `json:"app_id"` - EnvID string `json:"env_id"` - Email string `json:"email"` - Role MemberRole `json:"role"` - Status InvitationStatus `json:"status"` - Token string `json:"token"` // never exposed after creation - TokenHash string `json:"-"` // bcrypt hash, stored in DB - InvitedBy id.ID `json:"invited_by"` - AcceptedBy *id.ID `json:"accepted_by,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - AcceptedAt *time.Time `json:"accepted_at,omitempty"` - DeclinedAt *time.Time `json:"declined_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]string `json:"metadata,omitempty"` - - // Populated when fetched with org details. - Org *Organization `json:"org,omitempty"` + ID id.InvitationID `json:"id"` // ainv_ prefix + OrgID id.OrgID `json:"org_id"` + Email string `json:"email"` + Role MemberRole `json:"role"` + InviterID id.UserID `json:"inviter_id"` + Status InvitationStatus `json:"status"` // pending, accepted, expired, declined + Token string `json:"-"` + TokenHash string `json:"-"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` } ``` -| Field | Description | -|-------|-------------| -| `ID` | Globally unique identifier with `ainv_` prefix | -| `Email` | Email address of the person being invited | -| `Role` | The role the invitee will receive upon acceptance (`owner`, `admin`, or `member`) | -| `Status` | Current lifecycle state (see below) | -| `Token` | Plain-text token included in the invitation link. Only available at creation time -- not stored. | -| `TokenHash` | Bcrypt hash of the token, stored in the database for verification | -| `InvitedBy` | User ID of the person who sent the invitation | -| `AcceptedBy` | User ID of the user who accepted (may differ from invitee if email changed) | -| `ExpiresAt` | When the invitation token becomes invalid | +Invitations expire after `Config.InvitationTTL` (7 days by default; a negative value turns expiry off). Accepting an expired one returns `ErrInvitationExpired`. -## Invitation statuses - -```go -const ( - InvitationStatusPending InvitationStatus = "pending" - InvitationStatusAccepted InvitationStatus = "accepted" - InvitationStatusDeclined InvitationStatus = "declined" - InvitationStatusExpired InvitationStatus = "expired" - InvitationStatusRevoked InvitationStatus = "revoked" -) -``` - -| Status | Description | -|--------|-------------| -| `pending` | The invitation was sent and is awaiting a response. The token is still valid if not past `ExpiresAt`. | -| `accepted` | The invitee accepted the invitation and is now a member of the organization | -| `declined` | The invitee explicitly declined the invitation | -| `expired` | The invitation was not acted upon before `ExpiresAt`. Authsome marks these during background sweeps. | -| `revoked` | An admin or owner manually cancelled the invitation before it was acted upon | - -## Sending an invitation - -```go -import "github.com/xraph/authsome/org" - -result, err := auth.Orgs().InviteMember(ctx, &org.InviteInput{ - OrgID: orgID, - Email: "alice@example.com", - Role: org.RoleMember, - Message: "Hi Alice, please join our org on Acme!", // optional personal note -}) -if err != nil { - return err -} - -// result.Token is the raw token -- it is only available here. -// Store it or embed it in the invitation URL before this call returns. -inviteURL := fmt.Sprintf("https://app.example.com/invites/%s", result.Token) -fmt.Printf("invitation sent: id=%s url=%s expires=%s\n", - result.Invitation.ID, inviteURL, result.Invitation.ExpiresAt) -``` +## Delivering invitations -The raw invitation token is returned exactly once — at creation time. After the response is returned, only the bcrypt hash is stored. You must embed the token in the invitation URL before it is lost. If a token is lost, revoke the invitation and send a new one. +The plugin doesn't send invitation emails, and the token never appears in an HTTP response (`Token` is tagged `json:"-"`). An invitation created through `POST /v1/orgs/:orgId/invitations` can't reach the invitee unless you deliver the token some other way. To send invitations today, create them from Go, where you set the token, and email the link yourself. -### What happens on invite - -1. Authsome generates a cryptographically random token (32 bytes, hex-encoded). -2. The token is bcrypt-hashed and the hash is stored in the database. -3. The plain token is embedded in the return value so your application can send the invitation email. -4. If the `notification` plugin is configured, Authsome triggers an invitation email automatically. -5. The `org.member.invited` webhook event is emitted. - -### Expiry configuration - -The invitation expiry is set by the organization plugin's `WithInvitationExpiry` option (default: 72 hours). Each invitation's `ExpiresAt` field records the exact deadline. - ```go -auth := authsome.New( - authsome.WithPlugin(organization.New( - organization.WithInvitationExpiry(7 * 24 * time.Hour), // 7 days - )), +import ( + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" ) -``` - -## Accepting an invitation - -The acceptance flow handles both existing users and new users: -```go -// Accept using the raw token from the invitation URL. -member, err := auth.Orgs().AcceptInvitation(ctx, &org.AcceptInvitationInput{ - Token: "7f3a2b...", // raw token from the URL parameter - UserID: acceptingUserID, -}) +token, err := account.GenerateVerificationToken() if err != nil { - // org.ErrInvitationNotFound -- token does not match any pending invitation - // org.ErrInvitationExpired -- token is past ExpiresAt - // org.ErrInvitationNotPending -- already accepted, declined, or revoked - // org.ErrAlreadyMember -- user is already a member of this org return err } -fmt.Printf("welcome! member id=%s role=%s\n", member.ID, member.Role) -``` - -### Acceptance with sign-up - -When a new user receives an invitation to an email address that has no existing account, you can create the account and accept the invitation atomically: - -```go -member, err := auth.Orgs().AcceptInvitationWithSignUp(ctx, &org.AcceptWithSignUpInput{ - Token: "7f3a2b...", - DisplayName: "Alice Nguyen", - Password: "correct-horse-battery-staple", -}) -``` - -This creates the user account, signs them in, and adds them to the organization in a single transaction. - -### What happens on acceptance - -1. Authsome looks up the pending invitation by hashing the provided token and comparing against stored hashes. -2. It verifies the invitation is in `pending` status and `ExpiresAt` is in the future. -3. The invitation status is updated to `accepted` and `AcceptedAt` is set. -4. A `Member` record is created with the invitation's `Role`. -5. The user is added to any default teams in the organization. -6. The `org.member.joined` webhook event is emitted. - -## Declining an invitation - -```go -err := auth.Orgs().DeclineInvitation(ctx, &org.DeclineInvitationInput{ - Token: "7f3a2b...", -}) -``` - -Declining marks the invitation as `declined` and emits no events. The inviting user is not notified (though you can subscribe to webhook events to build this). - -## Revoking an invitation - -Organization owners and admins can cancel a pending invitation: +inv := &organization.Invitation{ + ID: id.NewInvitationID(), + OrgID: orgID, + Email: "bob@example.com", + Role: organization.RoleMember, + InviterID: inviterID, + Status: organization.InvitationPending, + Token: token, +} +if err := orgs.CreateInvitation(ctx, inv); err != nil { + return err +} -```go -err := auth.Orgs().RevokeInvitation(ctx, orgID, invitationID) +sendInviteEmail(inv.Email, "https://myapp.com/invite?token="+token) // your code ``` -Revocation changes the status to `revoked` and invalidates the token. If the invitee tries to use the link afterward, they receive `ErrInvitationNotPending`. - -## Listing invitations - -```go -// All invitations for an organization. -result, err := auth.Orgs().ListInvitations(ctx, orgID, &org.ListInvitationsInput{ - Limit: 50, - Cursor: "", - StatusFilter: org.InvitationStatusPending, // optional -}) - -for _, inv := range result.Items { - fmt.Printf(" %s role=%s status=%s expires=%s\n", - inv.Email, inv.Role, inv.Status, inv.ExpiresAt.Format("2006-01-02")) -} -``` +## Accepting and declining -```go -// All pending invitations for a specific email address across all orgs. -invitations, err := auth.Orgs().ListInvitationsByEmail(ctx, "alice@example.com") -``` +The invitee signs in and your frontend posts the token: -## Resending an invitation +| Method | Path | Body | +|--------|------|------| +| `POST` | `/v1/orgs/invitations/accept` | `{"token": "..."}` | +| `POST` | `/v1/orgs/invitations/decline` | `{"token": "..."}` | -If the original email was lost or the link expired, revoke the old invitation and create a new one: +Accepting over HTTP only works for the user who owns the invited address (primary or a secondary email). Anyone else gets `ErrInvitationEmailMismatch` and the invitation stays pending. A new user signs up first, then accepts. -```go -// Revoke the expired one. -err := auth.Orgs().RevokeInvitation(ctx, orgID, oldInvitationID) - -// Send a fresh invitation. -result, err := auth.Orgs().InviteMember(ctx, &org.InviteInput{ - OrgID: orgID, - Email: "alice@example.com", - Role: org.RoleMember, -}) -``` +From Go, `orgs.AcceptInvitationAs(ctx, token, userID)` applies the same check, and `orgs.AcceptInvitation(ctx, token)` adds whichever account owns the address without checking who's asking, so call it only from trusted code. `orgs.DeclineInvitation(ctx, token)` declines. - -There is no `ResendInvitation` method because resending would require re-exposing a token that is no longer stored in plain text. Revoke and re-invite is the correct pattern. - +## Listing -## HTTP API endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/orgs/:org_id/invitations` | Send an invitation | -| `GET` | `/orgs/:org_id/invitations` | List invitations for an organization | -| `GET` | `/orgs/:org_id/invitations/:inv_id` | Get a specific invitation | -| `DELETE` | `/orgs/:org_id/invitations/:inv_id` | Revoke a pending invitation | -| `POST` | `/invitations/accept` | Accept an invitation by token | -| `POST` | `/invitations/decline` | Decline an invitation by token | -| `GET` | `/invitations/:token` | Get invitation details by token (for pre-filling the accept form) | - -## Error reference - -| Error | Description | -|-------|-------------| -| `org.ErrInvitationNotFound` | No invitation matches the provided token | -| `org.ErrInvitationExpired` | The invitation's `ExpiresAt` has passed | -| `org.ErrInvitationNotPending` | The invitation is not in `pending` status | -| `org.ErrAlreadyMember` | The user accepting the invitation is already a member of the organization | -| `org.ErrInvitationLimitReached` | The organization has too many pending invitations (configurable limit) | +`GET /v1/orgs/:orgId/invitations` (admin or above) lists an organization's invitations, and `orgs.ListInvitations(ctx, orgID)` does the same from Go. There's no route or method to revoke one. diff --git a/docs/content/docs/organizations/members.mdx b/docs/content/docs/organizations/members.mdx index dc41f706..fd33490d 100644 --- a/docs/content/docs/organizations/members.mdx +++ b/docs/content/docs/organizations/members.mdx @@ -1,49 +1,24 @@ --- title: Members -description: Manage organization membership with three built-in roles and full CRUD operations. +description: Organization membership, the owner, admin and member roles, and the rules for granting and removing them. --- -Every user who belongs to an organization is represented by a `Member` record. Members are scoped to an organization and carry one of three built-in roles that control what actions they can perform within that organization. +A member links a user to an organization with one of three roles. ## The Member model ```go -import "github.com/xraph/authsome/org" +import "github.com/xraph/authsome/organization" type Member struct { - ID id.ID `json:"id"` // amem_ prefix - OrgID id.ID `json:"org_id"` - UserID id.ID `json:"user_id"` - Role MemberRole `json:"role"` - AddedBy id.ID `json:"added_by"` // user who added this member - JoinedAt time.Time `json:"joined_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Populated when the member is fetched with user details. - User *user.User `json:"user,omitempty"` + ID id.MemberID `json:"id"` // amem_ prefix + 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"` } -``` - -| Field | Description | -|-------|-------------| -| `ID` | Unique member record identifier with `amem_` prefix | -| `OrgID` | The organization this membership belongs to | -| `UserID` | The user who is a member | -| `Role` | One of `owner`, `admin`, or `member` | -| `AddedBy` | The user who performed the add operation (audit trail) | -| `JoinedAt` | When the membership was created (may differ from invitation acceptance time if added directly) | - -## Member roles -Authsome defines three built-in member roles with a clear hierarchy: - -| Role | Constant | Description | -|------|----------|-------------| -| Owner | `org.RoleOwner` | Full control over the organization. Can manage all members (including other owners), delete the org, change billing settings, and perform all admin actions. Every organization must have at least one owner. | -| Admin | `org.RoleAdmin` | Can manage members and teams, configure integrations, update org settings, and invite new members. Cannot change other admins' roles or remove owners. | -| Member | `org.RoleMember` | Read access to organization resources the application grants them. Cannot manage other members, change org settings, or invite users. | - -```go const ( RoleOwner MemberRole = "owner" RoleAdmin MemberRole = "admin" @@ -51,144 +26,47 @@ const ( ) ``` - -These three roles are the built-in org-level roles. You can layer additional fine-grained permissions on top using the RBAC system. See [Org-Scoped Roles](/docs/organizations/org-scoped-roles) for details. - - -## Adding members - -Members are typically added through the invitation flow (see [Invitations](/docs/organizations/invitations)), but you can also add them directly if the user already exists in your system: - -```go -import "github.com/xraph/authsome/org" - -// Add an existing user as a member directly. -member, err := auth.Orgs().AddMember(ctx, &org.AddMemberInput{ - OrgID: orgID, - UserID: targetUserID, - Role: org.RoleMember, -}) -if err != nil { - return err -} -fmt.Printf("added member: id=%s role=%s\n", member.ID, member.Role) -``` - - -Direct membership addition bypasses the invitation flow and does not send a notification email to the user. Use the invitation flow when you want to notify users and require their explicit acceptance before granting access. - - -The calling user must have the `owner` or `admin` role in the organization to add members. Admins can add `member`-role users only; adding an `admin` or `owner` requires the `owner` role. - -## Removing members - -```go -// Remove a member from an organization. -err := auth.Orgs().RemoveMember(ctx, orgID, memberID) -``` - -Removing a member: -- Deletes the `Member` record -- Removes the user from all teams within the organization -- Revokes all org-scoped role assignments for that user -- Emits the `org.member.removed` webhook event +The roles rank owner, then admin, then member. Over HTTP: -The calling user must be an `owner` or `admin`. Owners cannot remove other owners — they must first change the other owner's role to `admin`, then remove them. +- Reading the organization, its members and its teams needs any membership. +- Adding and removing members, inviting, and creating, updating or deleting teams need `admin` or above. +- Changing a member's role and deleting the organization need `owner`. +- Nobody can grant a role above their own or remove a member who outranks them. Only an owner can remove an owner, and the last owner can't be removed. -A user can also remove themselves (leave the organization): +These are organization roles, separate from the app's RBAC roles in Warden. -```go -err := auth.Orgs().LeaveOrg(ctx, orgID, currentUserID) -``` - - -An organization must always have at least one `owner`. If the last owner tries to leave or be removed, the operation returns `ErrLastOwner`. Transfer ownership to another member first. - +## Go API -## Changing roles +On the plugin (see [Organizations Overview](/docs/organizations/overview) for getting it): ```go -// Promote a member to admin. -updated, err := auth.Orgs().UpdateMemberRole(ctx, orgID, memberID, org.RoleAdmin) - -// Promote an admin to owner. -updated, err := auth.Orgs().UpdateMemberRole(ctx, orgID, memberID, org.RoleOwner) - -// Demote an admin to member. -updated, err := auth.Orgs().UpdateMemberRole(ctx, orgID, memberID, org.RoleMember) -``` - -Role change rules: -- Only `owner` role users can change roles at the `admin` and `owner` levels -- `admin` role users can change roles at the `member` level only -- Changing a user's role emits the `org.member.role_changed` webhook event - -## Listing members - -```go -// List all members of an organization. -result, err := auth.Orgs().ListMembers(ctx, orgID, &org.ListMembersInput{ - Limit: 50, - Cursor: "", - IncludeUserData: true, // populate the User field on each Member - RoleFilter: "", // optional: filter by "owner", "admin", or "member" +err := orgs.AddMember(ctx, &organization.Member{ + ID: id.NewMemberID(), + OrgID: orgID, + UserID: userID, + Role: organization.RoleMember, }) -if err != nil { - return err -} - -for _, m := range result.Items { - fmt.Printf(" %s (%s) role=%s joined=%s\n", - m.User.Email, m.ID, m.Role, m.JoinedAt.Format("2006-01-02")) -} - -if result.HasMore { - // Fetch next page using result.NextCursor -} -``` -### Filter options +members, err := orgs.ListMembers(ctx, orgID) // ListMembersPage for paging -| Field | Type | Description | -|-------|------|-------------| -| `Limit` | `int` | Maximum members per page (default: 50, max: 200) | -| `Cursor` | `string` | Pagination cursor from the previous `NextCursor` | -| `IncludeUserData` | `bool` | Whether to populate the `User` field on each `Member` record | -| `RoleFilter` | `string` | When non-empty, returns only members with that role | -| `Search` | `string` | Filter by display name or email (prefix match) | +updated, err := orgs.UpdateMemberRole(ctx, memberID, organization.RoleAdmin) -## Checking membership - -```go -// Check if a user is a member of an organization. -member, err := auth.Orgs().GetMember(ctx, orgID, userID) -if errors.Is(err, org.ErrMemberNotFound) { - // user is not a member -} - -// Check if a user has at least a given role. -hasRole, err := auth.Orgs().UserHasRole(ctx, orgID, userID, org.RoleAdmin) +err = orgs.RemoveMember(ctx, memberID) ``` -## HTTP API endpoints +The Go methods don't apply the HTTP ranking rules above; they're for code you trust. + +## HTTP API -The organization plugin registers these member-related routes: +| Method | Path | Body | Needs | +|--------|------|------|-------| +| `GET` | `/v1/orgs/:orgId/members` | | member | +| `POST` | `/v1/orgs/:orgId/members` | `{"user_id": "ausr_...", "role": "member"}` | admin | +| `PATCH` | `/v1/orgs/:orgId/members/:memberId` | `{"role": "admin"}` | owner | +| `DELETE` | `/v1/orgs/:orgId/members/:memberId` | | admin | -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/orgs/:org_id/members` | List all members of an organization | -| `GET` | `/orgs/:org_id/members/:member_id` | Get a specific member record | -| `POST` | `/orgs/:org_id/members` | Add a user as a member directly | -| `PATCH` | `/orgs/:org_id/members/:member_id` | Update a member's role | -| `DELETE` | `/orgs/:org_id/members/:member_id` | Remove a member from the organization | -| `DELETE` | `/orgs/:org_id/leave` | Leave the organization (self-removal) | +`role` defaults to `member`. -## Error reference +## The active organization -| Error | Description | -|-------|-------------| -| `org.ErrMemberNotFound` | No member record with the given ID exists in this org | -| `org.ErrAlreadyMember` | The user is already a member of this organization | -| `org.ErrLastOwner` | Cannot remove or demote the last owner of an organization | -| `org.ErrInsufficientRole` | The calling user does not have the required role to perform this action | -| `org.ErrCannotManageOwner` | An admin attempted to modify an owner's role or remove them | +A session can carry an active organization. `POST /v1/me/switch-org` changes it for the current session (the user has to be a member), and `middleware.OrgIDFrom(ctx)` reads it in your handlers. diff --git a/docs/content/docs/organizations/org-scoped-roles.mdx b/docs/content/docs/organizations/org-scoped-roles.mdx index 87db44d9..7c63047f 100644 --- a/docs/content/docs/organizations/org-scoped-roles.mdx +++ b/docs/content/docs/organizations/org-scoped-roles.mdx @@ -1,234 +1,73 @@ --- -title: Organization-Scoped Roles -description: RBAC role assignments scoped to a specific organization, layered on top of global roles. +title: Org-Scoped Roles +description: What org scoping means for roles today, and how roles and permissions actually work. --- -Authsome's RBAC system supports two assignment scopes: global and organization-scoped. Global roles apply across all of a user's organizations; org-scoped roles apply only when the user is operating within a specific organization. This lets you implement nuanced permission models where, for example, a user is a `viewer` globally but a `billing_manager` within their company's organization. +Authsome has two kinds of role. Organization member roles (`owner`, `admin`, `member`) come from the organization plugin and gate its own routes; see [Members](/docs/organizations/members). App roles live in Warden and back `eng.HasPermission` and `middleware.RequirePermission`. -## How org-scoped roles work + +App roles aren't scoped to an organization yet. `rbac.UserRole` has an `OrgID` field, and `POST /v1/roles/:roleId/assign` accepts `org_id`, but the Warden-backed role store drops it: the assignment is stored app-wide, and permission checks run against the app as the Warden tenant. A role assigned "in" one organization applies in every organization of that app. Until that changes, check organization membership yourself (with the organization plugin's member roles) when a permission should only hold inside one organization. + -When Authsome evaluates a permission check for a user, it assembles the full set of effective permissions from two sources: - -1. **Global role assignments** -- Roles assigned to the user that apply everywhere -2. **Org-scoped role assignments** -- Roles assigned to the user within the current organization context - -The resulting permission set is the union of both. This means org-scoped roles are additive -- they can grant additional permissions beyond what the global assignment gives, but they cannot revoke global permissions. - -``` -Effective permissions = global role permissions ∪ org-scoped role permissions -``` - -The organization context is extracted from the request context at permission-check time. You inject it using `authsome.WithOrgID(ctx, orgID)`. - -## Assigning an org-scoped role +## Creating a role with permissions ```go import ( - "github.com/xraph/authsome" + "github.com/xraph/authsome/id" "github.com/xraph/authsome/rbac" ) -// Assign the "billing_manager" role to a user within a specific org. -err := auth.RBAC().AssignOrgRole(ctx, &rbac.AssignOrgRoleInput{ - UserID: userID, - OrgID: orgID, - RoleID: billingManagerRoleID, -}) -if err != nil { +role := &rbac.Role{ + ID: id.NewRoleID().String(), + AppID: appID.String(), + Name: "Billing admin", + Slug: "billing-admin", +} +if err := eng.CreateRole(ctx, role); err != nil { return err } -``` - -The `AssignOrgRole` call is idempotent -- if the user already has the role in that org, it returns without error. -## Revoking an org-scoped role - -```go -err := auth.RBAC().RevokeOrgRole(ctx, &rbac.RevokeOrgRoleInput{ - UserID: userID, - OrgID: orgID, - RoleID: billingManagerRoleID, +err := eng.AddPermission(ctx, appID, &rbac.Permission{ + ID: id.NewPermissionID().String(), + RoleID: role.ID, + Action: "manage", + Resource: "billing", }) ``` -## Listing org-scoped roles for a user - -```go -// All org-scoped role assignments for a user in a specific org. -assignments, err := auth.RBAC().ListUserOrgRoles(ctx, userID, orgID) - -for _, a := range assignments { - fmt.Printf(" role=%s assigned_at=%s\n", a.Role.Name, a.AssignedAt) -} -``` - -## Checking org-scoped permissions - -Use `authsome.WithOrgID` to inject the org context before performing permission checks. The RBAC middleware does this automatically when it extracts the org from a request header or JWT claim. - -```go -// Inject org context. -ctx = authsome.WithOrgID(ctx, orgID) - -// Check permission in the org context. -allowed, err := auth.RBAC().Can(ctx, userID, "read", "billing") -``` - -The `Can` call evaluates both global and org-scoped assignments for the user and returns `true` if any of them grant `read:billing`. +Role inheritance lives in Warden as a parent slug. `CreateRole` doesn't convert `ParentID` into one, so set up parent roles in Warden (its DSL or API) rather than through this field. -## RBAC middleware with org context - -When using the built-in HTTP middleware, the org context is automatically extracted from a configurable source: +## Assigning and checking ```go -import "github.com/xraph/authsome/middleware" - -// Extract org ID from the "X-Org-ID" request header. -router.Use(middleware.OrgContext(auth, middleware.OrgFromHeader("X-Org-ID"))) - -// Or from a JWT claim. -router.Use(middleware.OrgContext(auth, middleware.OrgFromClaim("org_id"))) - -// Or from a URL path parameter (for routers that support it). -router.Use(middleware.OrgContext(auth, middleware.OrgFromPathParam("org_id"))) -``` - -After this middleware runs, all downstream `auth.RBAC().Can(ctx, ...)` calls automatically include org-scoped permissions. - -## Org role model - -Org-scoped role assignments are stored in the `OrgRoleAssignment` model: - -```go -type OrgRoleAssignment struct { - ID id.ID `json:"id"` - UserID id.ID `json:"user_id"` - OrgID id.ID `json:"org_id"` - RoleID id.ID `json:"role_id"` - AssignedBy id.ID `json:"assigned_by"` - AssignedAt time.Time `json:"assigned_at"` - - // Populated when fetched with details. - Role *Role `json:"role,omitempty"` -} -``` - -## Defining roles for org use - -Roles themselves are not org-specific -- you define them globally and assign them in any scope. A well-structured role schema for org-scoped use might look like: - -```go -// Create an org-level billing manager role. -billingRole, err := auth.RBAC().CreateRole(ctx, &rbac.CreateRoleInput{ - Name: "billing_manager", - DisplayName: "Billing Manager", - Description: "Can view and manage billing settings for an organization", - Permissions: []rbac.PermissionInput{ - {Action: "read", Resource: "billing"}, - {Action: "update", Resource: "billing"}, - {Action: "read", Resource: "invoices"}, - {Action: "export", Resource: "invoices"}, - }, +err := eng.AssignUserRole(ctx, appID, &rbac.UserRole{ + UserID: userID.String(), + RoleID: role.ID, }) -// Create an org-level viewer role. -viewerRole, err := auth.RBAC().CreateRole(ctx, &rbac.CreateRoleInput{ - Name: "org_viewer", - DisplayName: "Organization Viewer", - Description: "Read-only access to organization resources", - Permissions: []rbac.PermissionInput{ - {Action: "read", Resource: "org"}, - {Action: "read", Resource: "members"}, - {Action: "read", Resource: "teams"}, - }, -}) +allowed, err := eng.HasPermission(ctx, userID, "manage", "billing") ``` -## Permission inheritance with parent roles +`eng.UnassignUserRole(ctx, appID, userID, roleID)` removes the assignment, and `eng.ListUserRoles(ctx, userID)` lists a user's roles. -Roles support hierarchical parent-child relationships. When a role has a parent, it inherits all the parent's permissions. This lets you build role hierarchies where `org_admin` inherits from `org_viewer`: +## Guarding routes ```go -// Assign viewer as the parent of admin. -adminRole, err := auth.RBAC().CreateRole(ctx, &rbac.CreateRoleInput{ - Name: "org_admin", - ParentID: &viewerRoleID, // inherits all org_viewer permissions - Permissions: []rbac.PermissionInput{ - {Action: "update", Resource: "org"}, - {Action: "manage", Resource: "members"}, - {Action: "manage", Resource: "teams"}, - }, -}) -``` - -See [Role-Based Access Control](/docs/security/rbac) for the full role model and permission system documentation. - -## Full example - -```go -package main - -import ( - "context" - "fmt" - - "github.com/xraph/authsome" - "github.com/xraph/authsome/rbac" -) - -func setupOrgRBAC(auth *authsome.Authsome, ctx context.Context) error { - // 1. Create org-scoped roles. - viewerRole, err := auth.RBAC().CreateRole(ctx, &rbac.CreateRoleInput{ - Name: "org_viewer", - DisplayName: "Org Viewer", - Permissions: []rbac.PermissionInput{ - {Action: "read", Resource: "org"}, - {Action: "read", Resource: "members"}, - }, - }) - if err != nil { - return err - } - - editorRole, err := auth.RBAC().CreateRole(ctx, &rbac.CreateRoleInput{ - Name: "org_editor", - DisplayName: "Org Editor", - ParentID: &viewerRole.ID, - Permissions: []rbac.PermissionInput{ - {Action: "update", Resource: "org"}, - {Action: "manage", Resource: "members"}, - }, - }) - if err != nil { - return err - } - - // 2. Assign the editor role to a user within a specific org. - err = auth.RBAC().AssignOrgRole(ctx, &rbac.AssignOrgRoleInput{ - UserID: userID, - OrgID: orgID, - RoleID: editorRole.ID, - }) - if err != nil { - return err - } - - // 3. Check permission in the org context. - ctx = authsome.WithOrgID(ctx, orgID) - allowed, err := auth.RBAC().Can(ctx, userID, "manage", "members") - if err != nil { - return err - } - fmt.Printf("can manage members: %v\n", allowed) // true - return nil -} +router.GET("/billing", billingHandler, + forge.WithMiddleware(middleware.RequirePermission(eng, "manage", "billing"))) ``` -## HTTP API endpoints +## HTTP API | Method | Path | Description | |--------|------|-------------| -| `POST` | `/orgs/:org_id/roles` | Assign a role to a user within an org | -| `DELETE` | `/orgs/:org_id/roles/:assignment_id` | Revoke an org-scoped role assignment | -| `GET` | `/orgs/:org_id/users/:user_id/roles` | List all org-scoped role assignments for a user | -| `GET` | `/orgs/:org_id/roles/check` | Check if the current user has a permission in this org | +| `POST` | `/v1/roles` | Create a role | +| `GET` | `/v1/roles` | List roles | +| `GET`, `PATCH`, `DELETE` | `/v1/roles/:roleId` | Read, update or delete a role | +| `POST` | `/v1/roles/:roleId/permissions` | Add a permission | +| `GET` | `/v1/roles/:roleId/permissions` | List a role's permissions | +| `DELETE` | `/v1/roles/:roleId/permissions/:permissionId` | Remove a permission | +| `POST` | `/v1/roles/:roleId/assign` | Assign to a user (`user_id`; `org_id` is accepted but not applied) | +| `POST` | `/v1/roles/:roleId/unassign` | Remove from a user | + +See [RBAC](/docs/security/rbac) for the default roles that `WithBootstrap` seeds. diff --git a/docs/content/docs/organizations/overview.mdx b/docs/content/docs/organizations/overview.mdx index c52720ed..1520c36c 100644 --- a/docs/content/docs/organizations/overview.mdx +++ b/docs/content/docs/organizations/overview.mdx @@ -1,233 +1,106 @@ --- title: Organizations Overview -description: Multi-tenant organization management with CRUD operations, org-scoped features, and plugin-based setup. +description: Organizations, members, invitations and teams from the organization plugin, over Go and HTTP. --- -Organizations are the primary multi-tenancy boundary in Authsome. An organization groups users together under a shared namespace, giving them shared resources, roles, and configuration. Every organization belongs to an application and is environment-scoped, so users, sessions, and permissions for one environment are always isolated from another. +Organizations group users inside an app. The `organization` plugin adds them, along with members, invitations and teams. Each organization belongs to an app and an environment. ## The Organization model ```go -import "github.com/xraph/authsome/org" +import "github.com/xraph/authsome/organization" type Organization struct { - ID id.ID `json:"id"` // aorg_ prefix - AppID string `json:"app_id"` - EnvID string `json:"env_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description,omitempty"` - LogoURL string `json:"logo_url,omitempty"` - Color string `json:"color,omitempty"` // hex color for UI - IsPersonal bool `json:"is_personal"` - IsActive bool `json:"is_active"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID id.OrgID `json:"id"` // aorg_ prefix + 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"` // map[string]string + CreatedBy id.UserID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } ``` -| Field | Type | Description | -|-------|------|-------------| -| `ID` | `id.ID` | Globally unique identifier with `aorg_` prefix | -| `AppID` | `string` | The application this organization belongs to | -| `EnvID` | `string` | The environment this organization is scoped to | -| `Name` | `string` | Human-readable display name, e.g. `"Acme Corporation"` | -| `Slug` | `string` | URL-safe unique identifier within the app, e.g. `"acme-corp"`. Auto-generated from name if not provided. | -| `Description` | `string` | Optional description shown in dashboards and member invitations | -| `LogoURL` | `string` | URL to the organization's logo image | -| `Color` | `string` | Hex color code used for visual identification in dashboards (`#3B82F6`) | -| `IsPersonal` | `bool` | When `true`, this is a system-created personal organization for a single user | -| `IsActive` | `bool` | When `false`, the organization is suspended and members cannot authenticate with it | -| `Metadata` | `map[string]string` | Arbitrary key-value pairs for application-specific data | +Slugs are unique within an app. Creating an organization with a slug that's taken returns `ErrSlugTaken` from the plugin package (409 over HTTP). `GET /v1/orgs/check-slug` tells you whether a slug is free. -### Slug constraints - -Slugs are unique within an application-environment pair. The slug must: -- Contain only lowercase letters, numbers, and hyphens -- Start and end with a letter or number -- Be between 2 and 64 characters long - -Authsome auto-generates a slug from the organization name by lowercasing, replacing spaces with hyphens, and stripping non-alphanumeric characters. If the generated slug is already taken, a short random suffix is appended. - -## Setting up the Organization plugin - -The organization features are provided by the `organization` plugin. Register it during Authsome initialization: +## Setting up the plugin ```go import ( + "time" + "github.com/xraph/authsome" - "github.com/xraph/authsome/plugins/organization" + orgplugin "github.com/xraph/authsome/plugins/organization" ) -auth := authsome.New( - authsome.WithPlugin(organization.New( - organization.WithMaxOrgsPerUser(10), - organization.WithPersonalOrgEnabled(true), - organization.WithDefaultMemberRole("member"), - )), +orgs := orgplugin.New(orgplugin.Config{ + InvitationTTL: 72 * time.Hour, // default 7 days; negative means invitations never expire +}) + +eng, err := authsome.NewEngine( + // plus WithStore, WithChronicle and WithWarden (see Getting Started) + authsome.WithPlugin(orgs), ) ``` -### Plugin options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `WithMaxOrgsPerUser` | `int` | `0` (unlimited) | Maximum number of organizations a single user can create. `0` disables the limit. | -| `WithPersonalOrgEnabled` | `bool` | `true` | Whether to auto-create a personal organization for each new user at sign-up | -| `WithDefaultMemberRole` | `string` | `"member"` | The role assigned to new members when they join via invitation | -| `WithTeamsEnabled` | `bool` | `true` | Whether to enable team management within organizations | -| `WithInvitationExpiry` | `time.Duration` | `72h` | How long invitation tokens remain valid before expiring | -| `WithSlugFromName` | `bool` | `true` | Whether to auto-generate slugs from organization names | +`Config` has two fields: `InvitationTTL` and `PathPrefix`. Standalone, mount the plugin's routes the way [Getting Started](/docs/getting-started) does (the loop over `eng.Plugins().RouteProviders()`); the Forge extension mounts them for you. -## CRUD operations +## Go API -### Create an organization +The operations are methods on the plugin. Keep the value you passed to `WithPlugin`, or look it up with `eng.Plugin("organization").(*orgplugin.Plugin)`. ```go import ( - "context" - "github.com/xraph/authsome/org" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" ) -// Create a new organization. -created, err := auth.Orgs().Create(ctx, &org.CreateInput{ - Name: "Acme Corporation", - Slug: "acme-corp", // optional, auto-generated if omitted - Description: "The main org", - LogoURL: "https://cdn.example.com/logos/acme.png", - Color: "#3B82F6", - OwnerUserID: currentUserID, - Metadata: map[string]string{ - "billing_plan": "enterprise", - "region": "us-east-1", - }, -}) -if err != nil { +org := &organization.Organization{ + ID: id.NewOrgID(), + AppID: appID, + EnvID: envID, + Name: "Acme Corporation", + Slug: "acme", + CreatedBy: userID, + Metadata: organization.Metadata{"plan": "enterprise"}, +} +if err := orgs.CreateOrganization(ctx, org); err != nil { return err } -fmt.Printf("created org: id=%s slug=%s\n", created.ID, created.Slug) -``` - -The creating user is automatically added as an `owner` member of the organization. - -### Get an organization - -```go -// By ID. -o, err := auth.Orgs().GetByID(ctx, orgID) - -// By slug (within the current app/env). -o, err := auth.Orgs().GetBySlug(ctx, "acme-corp") ``` -### Update an organization +`CreateOrganization` adds `CreatedBy` as an `owner` member. You set the ID; the plugin doesn't generate one. -```go -updated, err := auth.Orgs().Update(ctx, orgID, &org.UpdateInput{ - Name: "Acme Corporation (Renamed)", - Description: "Updated description", - Color: "#10B981", -}) -``` +| Method | Description | +|--------|-------------| +| `CreateOrganization(ctx, *Organization)` | Create, and add the creator as owner | +| `GetOrganization(ctx, orgID)` | Get by ID | +| `UpdateOrganization(ctx, *Organization)` | Save changes | +| `DeleteOrganization(ctx, orgID)` | Delete | +| `ListUserOrganizations(ctx, userID)` | Organizations a user belongs to (`ListUserOrganizationsPage` pages) | +| `AdminListOrganizations(ctx, appID)` | Every organization in an app | +| `IsOrgSlugAvailable(ctx, appID, slug)` | Slug check | -Only the fields present in `UpdateInput` are modified. Pass `nil` for fields you want to leave unchanged. +Members, invitations and teams have their own pages: [Members](/docs/organizations/members), [Invitations](/docs/organizations/invitations) and [Teams](/docs/organizations/teams). -### Deactivate and delete +## HTTP API -```go -// Suspend the organization -- members can no longer sign in under it. -err := auth.Orgs().Deactivate(ctx, orgID) +The plugin registers these routes under `/v1` (under `/authsome/v1` with the Forge extension): -// Reactivate a suspended organization. -err := auth.Orgs().Activate(ctx, orgID) - -// Permanently delete the organization and all its members, teams, and invitations. -// This cannot be undone. -err := auth.Orgs().Delete(ctx, orgID) -``` - - -Deleting an organization permanently removes all member records, team memberships, and pending invitations. It does not delete the user accounts themselves. Consider deactivating instead of deleting if you may need to restore access later. - - -### List organizations - -```go -// All organizations for the current app/env. -orgs, err := auth.Orgs().List(ctx, &org.ListInput{ - Limit: 50, - Cursor: "", // leave empty for first page -}) - -// Organizations a specific user belongs to. -orgs, err := auth.Orgs().ListByUser(ctx, userID, &org.ListInput{ - Limit: 50, -}) -``` - -The list result uses cursor-based pagination: - -```go -type ListResult struct { - Items []*Organization `json:"items"` - NextCursor string `json:"next_cursor,omitempty"` - HasMore bool `json:"has_more"` -} -``` - -Pass `NextCursor` as the `Cursor` input on the next call to retrieve the subsequent page. - -## Org-scoped features - -Organizations scope the following features: - -- **Members** -- Each organization has its own member roster with roles (`owner`, `admin`, `member`). See [Members](/docs/organizations/members). -- **Teams** -- Sub-groups within an organization for delegated access control. See [Teams](/docs/organizations/teams). -- **Invitations** -- Email-based invitation flow for adding members. See [Invitations](/docs/organizations/invitations). -- **Roles** -- RBAC roles can be assigned at the org level in addition to the global level. See [Org-Scoped Roles](/docs/organizations/org-scoped-roles). -- **Branding** -- Per-org logos, colors, and custom CSS. See [Branding](/docs/dynamic-forms/branding). -- **Webhooks** -- Org lifecycle events (`org.created`, `org.updated`, `org.member.invited`, etc.) are emitted via the webhook system. +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/v1/orgs` | Create an organization (`name`, `slug`, optional `logo`, `app_id`) | +| `GET` | `/v1/orgs` | Organizations the signed-in user belongs to | +| `GET` | `/v1/orgs/check-slug` | Whether a slug is free | +| `GET` | `/v1/orgs/:orgId` | Get one | +| `PATCH` | `/v1/orgs/:orgId` | Update `name` or `logo` | +| `DELETE` | `/v1/orgs/:orgId` | Delete | -## HTTP API endpoints +The member, invitation and team routes are listed on their pages. `POST /v1/me/switch-org` moves the current session to another organization the user belongs to. -The organization plugin registers the following routes under the `/api/v1` prefix: +## Events -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/orgs` | Create a new organization | -| `GET` | `/orgs` | List organizations for the authenticated user | -| `GET` | `/orgs/:org_id` | Get organization by ID | -| `GET` | `/orgs/slug/:slug` | Get organization by slug | -| `PATCH` | `/orgs/:org_id` | Update an organization | -| `DELETE` | `/orgs/:org_id` | Delete an organization | -| `POST` | `/orgs/:org_id/activate` | Activate a suspended organization | -| `POST` | `/orgs/:org_id/deactivate` | Suspend an organization | -| `GET` | `/users/me/orgs` | List organizations the current user belongs to | - -## Events emitted - -When organization operations complete, Authsome emits webhook events: - -| Event | Trigger | -|-------|---------| -| `org.created` | A new organization was created | -| `org.updated` | Organization details were changed | -| `org.member.invited` | A new member was invited | -| `org.member.joined` | An invited member accepted and joined | -| `org.member.removed` | A member was removed | -| `org.member.role_changed` | A member's role was changed | - -See [Webhooks](/docs/security/webhooks) for payload schemas and delivery configuration. - -## Personal organizations - -When `WithPersonalOrgEnabled(true)` is set (the default), Authsome automatically creates a personal organization for each new user at sign-up. Personal organizations: - -- Have `IsPersonal: true` -- Are named after the user's display name or email -- Have the user as the sole `owner` member -- Cannot be deleted (only the user account deletion removes it) -- Do not appear in invitation flows - -Personal organizations let you use a single org-scoped permission model for both individual and team users without special-casing in your application logic. +Organization changes are recorded through the hook bus (and so to Chronicle) as `org.create`, `org.update`, `org.delete`, `org.member.add`, `org.member.remove`, `org.member.role_change`, `org.invitation.accept`, `org.invitation.decline` and the `org.team.*` actions. With a Relay bridge, the plugin also sends the webhook events `org.created`, `org.member.role_changed`, `org.invitation.accepted`, `org.invitation.declined` and `org.team.created`. See [Webhooks](/docs/security/webhooks). diff --git a/docs/content/docs/organizations/teams.mdx b/docs/content/docs/organizations/teams.mdx index 577ee97c..68d2d9f1 100644 --- a/docs/content/docs/organizations/teams.mdx +++ b/docs/content/docs/organizations/teams.mdx @@ -1,217 +1,52 @@ --- title: Teams -description: Sub-groups within organizations for delegated access control and resource assignment. +description: Named groups inside an organization. --- -Teams are sub-groups within an organization. They let you organize members into logical units — engineering, design, operations — and assign permissions or resources to the team as a whole rather than to individual users. Any organization member can be added to multiple teams. +Teams are named groups inside an organization, such as Engineering or Support. ## The Team model ```go -import "github.com/xraph/authsome/org" +import "github.com/xraph/authsome/organization" type Team struct { - ID id.ID `json:"id"` // atm_ prefix - OrgID id.ID `json:"org_id"` - AppID string `json:"app_id"` - EnvID string `json:"env_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description,omitempty"` - IsDefault bool `json:"is_default"` // auto-joined by new org members - Metadata map[string]string `json:"metadata,omitempty"` - MemberCount int `json:"member_count"` - CreatedBy id.ID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID id.TeamID `json:"id"` // atm_ prefix + OrgID id.OrgID `json:"org_id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } ``` -| Field | Description | -|-------|-------------| -| `ID` | Globally unique identifier with `atm_` prefix | -| `OrgID` | The organization this team belongs to | -| `Name` | Display name for the team, e.g. `"Engineering"` | -| `Slug` | URL-safe unique identifier within the org, e.g. `"engineering"` | -| `IsDefault` | When `true`, all new organization members are automatically added to this team | -| `MemberCount` | Denormalized count of current team members (updated on add/remove) | -| `CreatedBy` | User ID of the team creator | - -## Team membership model - -```go -type TeamMember struct { - ID id.ID `json:"id"` - TeamID id.ID `json:"team_id"` - OrgID id.ID `json:"org_id"` - UserID id.ID `json:"user_id"` - AddedBy id.ID `json:"added_by"` - JoinedAt time.Time `json:"joined_at"` - - // Populated when fetched with user details. - User *user.User `json:"user,omitempty"` -} -``` - -## Creating teams - -```go -import "github.com/xraph/authsome/org" - -team, err := auth.Orgs().CreateTeam(ctx, &org.CreateTeamInput{ - OrgID: orgID, - Name: "Backend Engineering", - Description: "Server-side engineers responsible for API and infrastructure", - IsDefault: false, - Metadata: map[string]string{ - "slack_channel": "#backend", - "oncall_rotation": "pagerduty-backend", - }, -}) -if err != nil { - return err -} -fmt.Printf("created team: id=%s slug=%s\n", team.ID, team.Slug) -``` - -Only `owner` and `admin` org members can create teams. - -## Fetching teams - -```go -// By ID. -team, err := auth.Orgs().GetTeam(ctx, orgID, teamID) - -// By slug within the organization. -team, err := auth.Orgs().GetTeamBySlug(ctx, orgID, "backend-engineering") -``` - -## Updating teams - -```go -updated, err := auth.Orgs().UpdateTeam(ctx, orgID, teamID, &org.UpdateTeamInput{ - Name: "Platform Engineering", - Description: "Formerly backend engineering", - IsDefault: false, -}) -``` - -## Deleting teams - -```go -err := auth.Orgs().DeleteTeam(ctx, orgID, teamID) -``` - -Deleting a team removes all `TeamMember` records for that team. It does not remove the users from the parent organization. The operation cannot be undone. - -## Listing teams - -```go -// All teams in an organization. -result, err := auth.Orgs().ListTeams(ctx, orgID, &org.ListTeamsInput{ - Limit: 50, - Cursor: "", -}) - -for _, t := range result.Items { - fmt.Printf(" %s (slug=%s members=%d)\n", t.Name, t.Slug, t.MemberCount) -} - -// Teams a specific user belongs to within an org. -result, err := auth.Orgs().ListTeamsByUser(ctx, orgID, userID, &org.ListTeamsInput{ - Limit: 50, -}) -``` - -## Adding members to a team - -A user must be an organization member before being added to a team. Adding them to the org first (or via invitation) is a prerequisite. - -```go -// Add an org member to a team. -teamMember, err := auth.Orgs().AddTeamMember(ctx, &org.AddTeamMemberInput{ - OrgID: orgID, - TeamID: teamID, - UserID: targetUserID, -}) -if err != nil { - // org.ErrMemberNotFound -- user is not an org member - // org.ErrAlreadyTeamMember -- user is already on this team - return err -} -``` - -```go -// Add multiple members in a single call. -err := auth.Orgs().AddTeamMembers(ctx, orgID, teamID, []id.ID{ - userID1, - userID2, - userID3, -}) -``` - -## Removing members from a team - -```go -// Remove a single member from a team. -err := auth.Orgs().RemoveTeamMember(ctx, orgID, teamID, userID) -``` - -Removing a team member only removes the `TeamMember` record. The user remains a member of the parent organization. + +A team is a name and a slug. The plugin has no team membership: no routes, no methods and no table link users to teams. If you need membership, keep it in your own tables keyed by the team ID. + -## Listing team members +## Go API ```go -result, err := auth.Orgs().ListTeamMembers(ctx, orgID, teamID, &org.ListMembersInput{ - Limit: 50, - Cursor: "", - IncludeUserData: true, -}) - -for _, m := range result.Items { - fmt.Printf(" %s joined=%s\n", m.User.Email, m.JoinedAt.Format("2006-01-02")) +team := &organization.Team{ + ID: id.NewTeamID(), + OrgID: orgID, + Name: "Engineering", + Slug: "engineering", } -``` - -## Default teams - -A team marked as `IsDefault: true` automatically adds every new organization member as a team member. This is useful for org-wide teams like `all-hands` or `announcements`: +err := orgs.CreateTeam(ctx, team) -```go -// Create an all-hands team. -_, err := auth.Orgs().CreateTeam(ctx, &org.CreateTeamInput{ - OrgID: orgID, - Name: "All Hands", - IsDefault: true, -}) +t, err := orgs.GetTeam(ctx, teamID) +err = orgs.UpdateTeam(ctx, t) +teams, err := orgs.ListTeams(ctx, orgID) // ListTeamsPage for paging +err = orgs.DeleteTeam(ctx, teamID) ``` -When a user joins the organization (through invitation acceptance or direct member addition), Authsome automatically adds them to every default team. This happens transactionally -- if the team join fails, the membership addition is rolled back. - - -You can have multiple default teams. All of them receive new members automatically. Existing members are not retroactively added when a team is set to `IsDefault: true`. - - -## HTTP API endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/orgs/:org_id/teams` | Create a new team | -| `GET` | `/orgs/:org_id/teams` | List all teams in an organization | -| `GET` | `/orgs/:org_id/teams/:team_id` | Get a team by ID | -| `GET` | `/orgs/:org_id/teams/slug/:slug` | Get a team by slug | -| `PATCH` | `/orgs/:org_id/teams/:team_id` | Update a team | -| `DELETE` | `/orgs/:org_id/teams/:team_id` | Delete a team | -| `GET` | `/orgs/:org_id/teams/:team_id/members` | List team members | -| `POST` | `/orgs/:org_id/teams/:team_id/members` | Add a member to a team | -| `DELETE` | `/orgs/:org_id/teams/:team_id/members/:user_id` | Remove a member from a team | -| `GET` | `/users/me/orgs/:org_id/teams` | List teams the current user belongs to | - -## Error reference +## HTTP API -| Error | Description | -|-------|-------------| -| `org.ErrTeamNotFound` | No team with the given ID exists in this organization | -| `org.ErrTeamSlugTaken` | A team with that slug already exists in this organization | -| `org.ErrAlreadyTeamMember` | The user is already a member of this team | -| `org.ErrMemberNotFound` | The user is not a member of the parent organization | +| Method | Path | Body | Needs | +|--------|------|------|-------| +| `GET` | `/v1/orgs/:orgId/teams` | | member | +| `POST` | `/v1/orgs/:orgId/teams` | `{"name": "Engineering", "slug": "engineering"}` | admin | +| `GET` | `/v1/orgs/:orgId/teams/:teamId` | | member | +| `PATCH` | `/v1/orgs/:orgId/teams/:teamId` | | admin | +| `DELETE` | `/v1/orgs/:orgId/teams/:teamId` | | admin | diff --git a/docs/content/docs/plugins/apikey.mdx b/docs/content/docs/plugins/apikey.mdx index befebb0e..e3efa137 100644 --- a/docs/content/docs/plugins/apikey.mdx +++ b/docs/content/docs/plugins/apikey.mdx @@ -14,9 +14,10 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(apikey.New(apiKeyStore, apikey.Config{ - PathPrefix: "/v1/auth/keys", + PathPrefix: "/v1/keys", MaxKeysPerUser: 10, DefaultExpiry: 90 * 24 * time.Hour, })), @@ -27,7 +28,7 @@ eng, err := authsome.NewEngine( | Option | Type | Default | Description | |--------|------|---------|-------------| -| `PathPrefix` | `string` | `"/v1/auth/keys"` | HTTP path prefix for API key management routes | +| `PathPrefix` | `string` | `"/v1/keys"` | HTTP path prefix for API key management routes | | `MaxKeysPerUser` | `int` | `0` (unlimited) | Maximum number of active keys per user | | `DefaultExpiry` | `time.Duration` | `0` (never) | Default TTL for newly created keys | @@ -42,7 +43,7 @@ eng, err := authsome.NewEngine( ## API key generation -`POST /v1/auth/keys` +`POST /v1/keys` All key-management routes require an authenticated session. @@ -94,15 +95,15 @@ Keys can be scoped to specific permissions: } ``` -Scopes are stored on the key and can be checked by your application logic. The plugin does not enforce scope semantics -- it stores and retrieves them. +Scopes are stored on the key and can be checked by your application logic. The plugin does not enforce scope semantics: it stores and retrieves them. ## Listing keys -`GET /v1/auth/keys?app_id=aapp_01j9...` +`GET /v1/keys?app_id=aapp_01j9...` Optionally filter by user: -`GET /v1/auth/keys?app_id=aapp_01j9...&user_id=ausr_01j9...` +`GET /v1/keys?app_id=aapp_01j9...&user_id=ausr_01j9...` **Response:** @@ -128,7 +129,7 @@ The raw key is never returned in list responses. Only the prefix is shown for id ## Key revocation -`DELETE /v1/auth/keys/:keyId` +`DELETE /v1/keys/:keyId` Marks the key as revoked. Revoked keys are rejected during authentication. The key record is preserved for audit purposes. @@ -183,15 +184,15 @@ their own keys unless they hold the `manage`/`apikey` permission. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/keys` | Create a new API key (defaults to the caller) | -| `GET` | `/v1/auth/keys` | List your keys; app-wide listing requires `manage`/`apikey` | -| `DELETE` | `/v1/auth/keys/:keyId` | Revoke one of your keys | +| `POST` | `/v1/keys` | Create a new API key (defaults to the caller) | +| `GET` | `/v1/keys` | List your keys; app-wide listing requires `manage`/`apikey` | +| `DELETE` | `/v1/keys/:keyId` | Revoke one of your keys | ## Observability The plugin emits events for: -- `apikey.created` -- New API key generated -- `apikey.revoked` -- API key revoked +- `apikey.created`: New API key generated +- `apikey.revoked`: API key revoked Events are sent to Chronicle (audit), Relay (webhooks), and the global hook bus. diff --git a/docs/content/docs/plugins/consent.mdx b/docs/content/docs/plugins/consent.mdx index 32868683..ab605c27 100644 --- a/docs/content/docs/plugins/consent.mdx +++ b/docs/content/docs/plugins/consent.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(consent.New()), ) @@ -51,7 +52,7 @@ You define purposes based on your application's needs. The plugin does not enfor ## Recording consent -`POST /v1/auth/consent/grant` +`POST /v1/consent/grant` ```json { @@ -79,14 +80,14 @@ You define purposes based on your application's needs. The plugin does not enfor Each consent record captures: -- **Purpose** -- What the user is consenting to -- **Version** -- The policy version this consent applies to (for tracking policy changes) -- **IP address** -- The client IP at the time of consent (from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`) -- **Timestamp** -- When consent was granted +- Purpose: What the user is consenting to +- Version: The policy version this consent applies to (for tracking policy changes) +- IP address: The client IP at the time of consent (from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`) +- Timestamp: When consent was granted ## Consent withdrawal -`POST /v1/auth/consent/revoke` +`POST /v1/consent/revoke` ```json { @@ -105,7 +106,7 @@ Revoking consent marks the record as withdrawn. The original consent record is p ## Listing consent records -`GET /v1/auth/consent` +`GET /v1/consent` Query parameters: @@ -143,9 +144,9 @@ Query parameters: Every consent operation is recorded through multiple observability channels: -- **Chronicle** -- Audit events with action, resource, actor, and metadata -- **Relay** -- Webhook events for external systems (`consent.granted`, `consent.revoked`) -- **Hook bus** -- Internal hook events for other plugins to react to consent changes +- Chronicle: Audit events with action, resource, actor, and metadata +- Relay: Webhook events for external systems (`consent.granted`, `consent.revoked`) +- Hook bus: Internal hook events for other plugins to react to consent changes ## GDPR data export @@ -168,16 +169,16 @@ When the engine processes a GDPR data export request, consent records are includ The consent plugin can be used with a frontend cookie consent banner: 1. The frontend displays a cookie consent banner on first visit -2. When the user makes their choices, the frontend calls `POST /v1/auth/consent/grant` for each accepted purpose +2. When the user makes their choices, the frontend calls `POST /v1/consent/grant` for each accepted purpose 3. The frontend stores a local flag to suppress the banner on subsequent visits -4. When the user changes preferences, call `POST /v1/auth/consent/revoke` for withdrawn purposes and `POST /v1/auth/consent/grant` for newly accepted ones +4. When the user changes preferences, call `POST /v1/consent/revoke` for withdrawn purposes and `POST /v1/consent/grant` for newly accepted ones ## API routes | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/consent/grant` | Record consent for a purpose | -| `POST` | `/v1/auth/consent/revoke` | Withdraw consent for a purpose | -| `GET` | `/v1/auth/consent` | List consent records for the authenticated user | +| `POST` | `/v1/consent/grant` | Record consent for a purpose | +| `POST` | `/v1/consent/revoke` | Withdraw consent for a purpose | +| `GET` | `/v1/consent` | List consent records for the authenticated user | All endpoints require authentication (`Authorization: Bearer` header). diff --git a/docs/content/docs/plugins/creating-plugins.mdx b/docs/content/docs/plugins/creating-plugins.mdx index 61bfd6ff..d6dbd427 100644 --- a/docs/content/docs/plugins/creating-plugins.mdx +++ b/docs/content/docs/plugins/creating-plugins.mdx @@ -91,7 +91,7 @@ func (p *Plugin) OnBeforeSignUp(ctx context.Context, req *account.SignUpRequest) } ``` -**After-hooks** run after the operation completes. Errors are logged but never propagated -- they must not block the pipeline. +**After-hooks** run after the operation completes. Errors are logged but never propagated: they must not block the pipeline. ## Step 4: Add database migrations @@ -148,7 +148,7 @@ func (p *Plugin) RegisterRoutes(r any) error { return fmt.Errorf("auditlog: expected forge.Router, got %T", r) } - g := router.Group("/v1/auth/audit", forge.WithGroupTags("Audit Log")) + g := router.Group("/v1/audit", forge.WithGroupTags("Audit Log")) return g.GET("/events", p.handleListEvents, forge.WithSummary("List audit events"), @@ -200,6 +200,7 @@ Add your plugin to the engine during construction: ```go eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(password.New()), authsome.WithPlugin(auditlog.New(auditStore)), @@ -232,6 +233,7 @@ For integration tests, create a full engine with your plugin and exercise it thr ```go func TestAuditLogIntegration(t *testing.T) { eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(memoryStore), authsome.WithPlugin(auditlog.New(memoryAuditStore)), ) diff --git a/docs/content/docs/plugins/device.mdx b/docs/content/docs/plugins/device.mdx index 0ab79371..e973a18d 100644 --- a/docs/content/docs/plugins/device.mdx +++ b/docs/content/docs/plugins/device.mdx @@ -3,23 +3,20 @@ title: Device Plugin description: Device fingerprinting, trust management, session-device binding, and suspicious device detection. --- -The `device` plugin tracks user devices across sessions. It records device fingerprints, manages trusted devices, binds sessions to specific devices, and enables suspicious device detection for security-sensitive applications. +Authsome tracks user devices across sessions. It records device fingerprints, manages trusted devices, binds sessions to specific devices, and enables suspicious device detection for security-sensitive applications. ## Setup ```go -import ( - "github.com/xraph/authsome" - "github.com/xraph/authsome/device" -) +import "github.com/xraph/authsome" eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), - authsome.WithDeviceStore(deviceStore), ) ``` -The device tracking functionality is built into the core engine and uses the `device.Store` interface for persistence. It does not require a separate plugin registration -- configure a device store to enable it. +There's no `device` plugin to register and no separate device store to configure. Device tracking is part of the core engine: `store.Store` includes `device.Store`, so every backend persists devices, and the engine's `RegisterDevice`, `GetDevice`, `ListUserDevices`, `TrustDevice` and `DeleteDevice` methods and the `/v1/devices` routes work out of the box. The separate `deviceverify` plugin adds a verification step for new devices. ## Device model @@ -27,20 +24,21 @@ Each tracked device stores the following information: ```go type Device struct { - 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"` - 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"` } ``` @@ -97,7 +95,7 @@ New devices start as untrusted. Users can mark a device as trusted to skip additional verification steps (like MFA) on subsequent sign-ins: -`PUT /v1/auth/devices/:deviceId/trust` +`PATCH /v1/devices/:deviceId/trust` ```json {"trusted": true} @@ -105,7 +103,7 @@ Users can mark a device as trusted to skip additional verification steps (like M ### Listing user devices -`GET /v1/auth/devices` +`GET /v1/devices` Returns all devices registered for the authenticated user: @@ -132,7 +130,7 @@ Returns all devices registered for the authenticated user: ### Removing a device -`DELETE /v1/auth/devices/:deviceId` +`DELETE /v1/devices/:deviceId` Removes the device record. Any sessions bound to this device are not automatically revoked. @@ -148,10 +146,10 @@ When device tracking is enabled, sessions are associated with the device they we The engine can detect potentially suspicious device activity: -- **New device sign-in** -- A user signs in from a device not seen before -- **IP address change** -- A known device appears from a different IP -- **Multiple devices** -- An unusual number of new devices in a short period -- **Impossible travel** -- Sign-ins from geographically distant locations in a short timeframe +- New device sign-in: A user signs in from a device not seen before +- IP address change: A known device appears from a different IP +- Multiple devices: An unusual number of new devices in a short period +- Impossible travel: Sign-ins from geographically distant locations in a short timeframe When suspicious activity is detected, the engine can: diff --git a/docs/content/docs/plugins/email.mdx b/docs/content/docs/plugins/email.mdx index 3232a677..b996936b 100644 --- a/docs/content/docs/plugins/email.mdx +++ b/docs/content/docs/plugins/email.mdx @@ -3,7 +3,7 @@ title: Email Plugin description: Email verification and transactional email notifications via the Mailer bridge. --- -The `email` plugin sends transactional emails for authentication events -- welcome emails on sign-up, verification emails for new users, password reset emails, and organization invitation emails. It integrates with the Mailer bridge to deliver emails through any configured provider. +The `email` plugin sends transactional emails for authentication events: welcome emails on sign-up, verification emails for new users, password reset emails, and organization invitation emails. It integrates with the Mailer bridge to deliver emails through any configured provider. ## Setup @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithMailer(mailerBridge), authsome.WithPlugin(email.New(email.Config{ @@ -47,8 +48,8 @@ When a new user is created with an unverified email, the plugin sends a verifica 1. The engine creates the user record 2. The `email` plugin receives the `OnAfterUserCreate` callback -3. If `EmailVerified` is `false`, the plugin sends a verification email with a link to `{BaseURL}/verify-email` -4. The user clicks the link, which calls `POST /v1/auth/verify-email` with the verification token +3. If `EmailVerified` is `false`, the plugin sends a verification email with a link to `{BaseURL}/verify-email`. The link carries no code or token, so the email can't complete verification on its own; the 6-digit code is delivered only by the `notification` plugin through Herald +4. The user clicks the link, which calls `POST /v1/verify-email` with the verification token ```go // The verification email includes a link like: @@ -73,7 +74,7 @@ subject, html, text := WelcomeEmail(name, cfg.AppName) ## Password reset email -The `SendPasswordReset` method is called by the engine's password reset flow. It is not a hook -- the engine calls it directly when a password reset is requested: +Nothing in Authsome calls `SendPasswordReset`; the engine's reset flow doesn't send email itself. Call it from your own code with a link that carries the reset token (`eng.ForgotPassword` returns the `*account.PasswordReset`): ```go err := emailPlugin.SendPasswordReset(ctx, email, name, resetURL) @@ -83,7 +84,7 @@ The reset URL includes the token so the user can complete the reset process. ## Invitation email -The `SendInvitation` method sends organization invitation emails: +The `SendInvitation` method sends organization invitation emails. The organization plugin doesn't call it, so call it yourself with the accept link: ```go err := emailPlugin.SendInvitation(ctx, email, inviterName, orgName, acceptURL) @@ -104,7 +105,7 @@ Templates are defined in the `templates.go` file within the email plugin package ## Integration with Mailer bridge -The email plugin requires a Mailer bridge to be configured on the engine. If no mailer is available, the plugin silently skips all email operations -- it never returns an error from hook callbacks. +The email plugin requires a Mailer bridge to be configured on the engine. If no mailer is available, the plugin silently skips all email operations: it never returns an error from hook callbacks. ```go // The bridge interface: diff --git a/docs/content/docs/plugins/magiclink.mdx b/docs/content/docs/plugins/magiclink.mdx index 08c1bb95..f07e0aa6 100644 --- a/docs/content/docs/plugins/magiclink.mdx +++ b/docs/content/docs/plugins/magiclink.mdx @@ -3,7 +3,7 @@ title: Magic Link Plugin description: Passwordless authentication via time-limited magic links delivered by email. --- -The `magiclink` plugin provides passwordless authentication. Users enter their email address, receive a one-time link, and clicking it authenticates them instantly -- no password required. +The `magiclink` plugin provides passwordless authentication. Users enter their email address, receive a one-time link, and clicking it authenticates them instantly: no password required. ## Setup @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(magiclink.New(magiclink.Config{ Mailer: magiclink.MailerFunc(func(ctx context.Context, email, token string) error { @@ -42,7 +43,7 @@ eng, err := authsome.NewEngine( |-----------|---------| | `Plugin` | Base plugin identity (`"magiclink"`) | | `OnInit` | Captures the store reference from the engine | -| `RouteProvider` | Registers `/v1/auth/magic-link/send` and `/verify` endpoints | +| `RouteProvider` | Registers `/v1/magic-link/send` and `/verify` endpoints | ## Authentication flow @@ -50,7 +51,7 @@ eng, err := authsome.NewEngine( **Request magic link**: The user submits their email address. -`POST /v1/auth/magic-link/send` +`POST /v1/magic-link/send` ```json {"email": "alice@example.com", "app_id": "myapp"} @@ -69,7 +70,7 @@ The response is always successful regardless of whether the email exists. This p **Verify magic link**: The user clicks the link, which sends the token to the verify endpoint. -`POST /v1/auth/magic-link/verify` +`POST /v1/magic-link/verify` ```json {"token": "the-token-from-email", "app_id": "myapp"} @@ -94,10 +95,10 @@ The plugin validates the token, checks expiration, ensures it has not been consu Magic link tokens are: -- **Cryptographically random** -- generated using `crypto/rand` -- **One-time use** -- consumed immediately on verification -- **Time-limited** -- expire after `TokenTTL` (default 10 minutes) -- **Stored server-side** -- tokens are verification records in the database, not JWTs +- Cryptographically random: generated using `crypto/rand` +- One-time use: consumed immediately on verification +- Time-limited: expire after `TokenTTL` (default 10 minutes) +- Stored server-side: tokens are verification records in the database, not JWTs If a token is expired or already consumed, the verify endpoint returns `401 Unauthorized`. @@ -126,8 +127,8 @@ magiclink.Config{ | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/magic-link/send` | Send a magic link to the given email | -| `POST` | `/v1/auth/magic-link/verify` | Verify a magic link token and create a session | +| `POST` | `/v1/magic-link/send` | Send a magic link to the given email | +| `POST` | `/v1/magic-link/verify` | Verify a magic link token and create a session | ## Per-app session configuration diff --git a/docs/content/docs/plugins/mfa.mdx b/docs/content/docs/plugins/mfa.mdx index a377ccb6..c8b7bc78 100644 --- a/docs/content/docs/plugins/mfa.mdx +++ b/docs/content/docs/plugins/mfa.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithSMSSender(smsBridge), // Required for SMS MFA authsome.WithPlugin(mfa.New(mfa.Config{ @@ -34,7 +35,7 @@ eng, err := authsome.NewEngine( |-----------|---------| | `Plugin` | Base plugin identity (`"mfa"`) | | `OnInit` | Captures SMS sender, Chronicle, Relay, hooks, logger, and ceremony store | -| `RouteProvider` | Registers all MFA endpoints under `/v1/auth/mfa` | +| `RouteProvider` | Registers all MFA endpoints under `/v1/mfa` | | `MigrationProvider` | Contributes `mfa_enrollments` and `mfa_recovery_codes` tables | ## TOTP enrollment @@ -43,7 +44,7 @@ TOTP (RFC 6238) is the recommended MFA method. It works with any authenticator a ### Step 1: Start enrollment -`POST /v1/auth/mfa/enroll` +`POST /v1/mfa/enroll` ```json {"method": "totp"} @@ -64,7 +65,7 @@ The `otpauth_url` can be rendered as a QR code for the user to scan with their a ### Step 2: Verify enrollment -`POST /v1/auth/mfa/verify` +`POST /v1/mfa/verify` ```json {"code": "123456"} @@ -94,7 +95,7 @@ SMS OTP provides a second factor using text messages. It requires an SMS bridge ### Enroll via SMS -`POST /v1/auth/mfa/enroll` +`POST /v1/mfa/enroll` ```json {"method": "sms", "phone": "+14155551234"} @@ -104,7 +105,7 @@ The plugin sends an initial verification code to the provided phone number. ### Send SMS code -`POST /v1/auth/mfa/sms/send` +`POST /v1/mfa/sms/send` ```json {"phone": "+14155551234"} @@ -122,7 +123,7 @@ The plugin sends an initial verification code to the provided phone number. ### Verify SMS code -`POST /v1/auth/mfa/sms/verify` +`POST /v1/mfa/sms/verify` ```json {"code": "123456"} @@ -132,7 +133,7 @@ The plugin sends an initial verification code to the provided phone number. During sign-in, if the user has MFA enrolled, the engine requires an MFA challenge before issuing a full session. -`POST /v1/auth/mfa/challenge` +`POST /v1/mfa/challenge` ```json {"code": "123456"} @@ -155,7 +156,7 @@ Recovery codes are generated when MFA is first verified. Each code is one-time u ### Verify a recovery code -`POST /v1/auth/mfa/recovery/verify` +`POST /v1/mfa/recovery/verify` ```json {"code": "a1b2c3d4e5"} @@ -172,7 +173,7 @@ Recovery codes are generated when MFA is first verified. Each code is one-time u ### Regenerate recovery codes -`POST /v1/auth/mfa/recovery/regenerate` +`POST /v1/mfa/recovery/regenerate` Generates a fresh set of recovery codes, replacing all existing codes. Requires an active MFA enrollment. @@ -186,7 +187,7 @@ Generates a fresh set of recovery codes, replacing all existing codes. Requires ## Disable MFA -`DELETE /v1/auth/mfa/enrollment` +`DELETE /v1/mfa/enrollment` Removes the MFA enrollment for the authenticated user. Recovery codes are not automatically deleted. @@ -207,22 +208,22 @@ This checks both TOTP and SMS enrollments. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/mfa/enroll` | Start MFA enrollment (TOTP or SMS) | -| `POST` | `/v1/auth/mfa/verify` | Verify TOTP code to confirm enrollment | -| `POST` | `/v1/auth/mfa/challenge` | Verify TOTP code during sign-in challenge | -| `DELETE` | `/v1/auth/mfa/enrollment` | Disable MFA for the authenticated user | -| `POST` | `/v1/auth/mfa/recovery/verify` | Verify a recovery code | -| `POST` | `/v1/auth/mfa/recovery/regenerate` | Generate new recovery codes | -| `POST` | `/v1/auth/mfa/sms/send` | Send an SMS verification code | -| `POST` | `/v1/auth/mfa/sms/verify` | Verify an SMS code | +| `POST` | `/v1/mfa/enroll` | Start MFA enrollment (TOTP or SMS) | +| `POST` | `/v1/mfa/verify` | Verify TOTP code to confirm enrollment | +| `POST` | `/v1/mfa/challenge` | Verify TOTP code during sign-in challenge | +| `DELETE` | `/v1/mfa/enrollment` | Disable MFA for the authenticated user | +| `POST` | `/v1/mfa/recovery/verify` | Verify a recovery code | +| `POST` | `/v1/mfa/recovery/regenerate` | Generate new recovery codes | +| `POST` | `/v1/mfa/sms/send` | Send an SMS verification code | +| `POST` | `/v1/mfa/sms/verify` | Verify an SMS code | ## Observability The MFA plugin emits audit events to Chronicle, webhook events to Relay, and hook events on the global bus for: -- `auth.mfa.enrolled` -- MFA enrollment started -- `auth.mfa.verified` -- MFA enrollment confirmed -- `auth.mfa.challenged` -- MFA challenge passed -- `auth.mfa.disabled` -- MFA enrollment removed -- `auth.mfa.recovery_used` -- Recovery code consumed -- `auth.mfa.recovery_regenerated` -- Recovery codes regenerated +- `auth.mfa.enrolled`: MFA enrollment started +- `auth.mfa.verified`: MFA enrollment confirmed +- `auth.mfa.challenged`: MFA challenge passed +- `auth.mfa.disabled`: MFA enrollment removed +- `auth.mfa.recovery_used`: Recovery code consumed +- `auth.mfa.recovery_regenerated`: Recovery codes regenerated diff --git a/docs/content/docs/plugins/notification.mdx b/docs/content/docs/plugins/notification.mdx index 7e4d9507..b24ea8c3 100644 --- a/docs/content/docs/plugins/notification.mdx +++ b/docs/content/docs/plugins/notification.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithHerald(heraldBridge), authsome.WithPlugin(notification.New(notification.Config{ diff --git a/docs/content/docs/plugins/oauth2provider.mdx b/docs/content/docs/plugins/oauth2provider.mdx index 01cfc2cc..a7f64394 100644 --- a/docs/content/docs/plugins/oauth2provider.mdx +++ b/docs/content/docs/plugins/oauth2provider.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(oauth2provider.New(oauth2provider.Config{ Issuer: "https://auth.example.com", @@ -46,7 +47,7 @@ The Authorization Code flow with PKCE is the recommended grant type for both pub ### Step 1: Authorization request -`GET /v1/auth/oauth/authorize` +`GET /v1/oauth/authorize` ``` ?response_type=code @@ -62,7 +63,7 @@ The user must be authenticated. The endpoint validates the client, redirect URI, ### Step 2: Token exchange -`POST /v1/auth/oauth/token` +`POST /v1/oauth/token` ```json { @@ -112,7 +113,7 @@ A client marked `first_party` on the admin API skips the page, since the user al A session the provider mints carries the `client_id` it was issued to, and that binding is enforced everywhere the token can be used: - The auth middleware never installs such a session on a request by itself. A route admits it with `middleware.RequireScope(scopes...)`, which checks the token holds every named scope and answers `403 insufficient_scope` otherwise; a route nobody tagged sees no session at all. Userinfo (`openid`), introspection and revocation are tagged by default; tag your own resource routes the same way. -- Refresh goes through the token endpoint's `grant_type=refresh_token` with client authentication. The refresh token must belong to that client, a DPoP-bound token must present a proof before anything rotates, a spent refresh token that is presented again revokes the whole family, and the session refresh route `/v1/auth/refresh` refuses OAuth2 refresh tokens outright. +- Refresh goes through the token endpoint's `grant_type=refresh_token` with client authentication. The refresh token must belong to that client, a DPoP-bound token must present a proof before anything rotates, a spent refresh token that is presented again revokes the whole family, and the session refresh route `/v1/refresh` refuses OAuth2 refresh tokens outright. - Revocation by an authenticated client only touches tokens issued to that client; anything else is answered as revoked without effect. - Userinfo returns the claims the token's scopes cover: `profile` for the name, `email` for the address and its verification, `phone` for the number. - The signed-in session that authorizes a client, or approves a device, must belong to the client's app. @@ -122,7 +123,7 @@ A session the provider mints carries the `client_id` it was issued to, and that Machine-to-machine authentication for confidential clients: -`POST /v1/auth/oauth/token` +`POST /v1/oauth/token` ```json { @@ -147,7 +148,7 @@ Client credentials tokens have no associated user. They are issued with an empty ## Token revocation -`POST /v1/auth/oauth/revoke` +`POST /v1/oauth/revoke` ```json { @@ -160,7 +161,7 @@ Follows RFC 7009. Always returns `200 OK` regardless of whether the token was fo ## OIDC UserInfo -`GET /v1/auth/oauth/userinfo` +`GET /v1/oauth/userinfo` Returns claims about the authenticated user (requires a valid access token): @@ -183,10 +184,10 @@ Returns the OIDC discovery document: ```json { "issuer": "https://auth.example.com", - "authorization_endpoint": "https://auth.example.com/v1/auth/oauth/authorize", - "token_endpoint": "https://auth.example.com/v1/auth/oauth/token", - "userinfo_endpoint": "https://auth.example.com/v1/auth/oauth/userinfo", - "revocation_endpoint": "https://auth.example.com/v1/auth/oauth/revoke", + "authorization_endpoint": "https://auth.example.com/v1/oauth/authorize", + "token_endpoint": "https://auth.example.com/v1/oauth/token", + "userinfo_endpoint": "https://auth.example.com/v1/oauth/userinfo", + "revocation_endpoint": "https://auth.example.com/v1/oauth/revoke", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "client_credentials"], "scopes_supported": ["openid", "profile", "email", "phone"], @@ -198,7 +199,7 @@ Returns the OIDC discovery document: ### Create a client -`POST /v1/auth/admin/oauth/clients` +`POST /v1/admin/oauth/clients` ```json { @@ -230,24 +231,24 @@ The `client_secret` is only returned at creation time. It is bcrypt-hashed befor ### List clients -`GET /v1/auth/admin/oauth/clients?app_id=aapp_01j9...` +`GET /v1/admin/oauth/clients?app_id=aapp_01j9...` ### Delete a client -`DELETE /v1/auth/admin/oauth/clients/:clientId` +`DELETE /v1/admin/oauth/clients/:clientId` ## API routes | Method | Path | Description | |--------|------|-------------| -| `GET` | `/v1/auth/oauth/authorize` | OAuth2 authorization endpoint | -| `POST` | `/v1/auth/oauth/token` | Token endpoint (code exchange, client credentials) | -| `POST` | `/v1/auth/oauth/revoke` | Token revocation | -| `GET` | `/v1/auth/oauth/userinfo` | OIDC UserInfo | +| `GET` | `/v1/oauth/authorize` | OAuth2 authorization endpoint | +| `POST` | `/v1/oauth/token` | Token endpoint (code exchange, client credentials) | +| `POST` | `/v1/oauth/revoke` | Token revocation | +| `GET` | `/v1/oauth/userinfo` | OIDC UserInfo | | `GET` | `/.well-known/openid-configuration` | OIDC Discovery | -| `POST` | `/v1/auth/admin/oauth/clients` | Create OAuth2 client | -| `GET` | `/v1/auth/admin/oauth/clients` | List OAuth2 clients | -| `DELETE` | `/v1/auth/admin/oauth/clients/:clientId` | Delete OAuth2 client | +| `POST` | `/v1/admin/oauth/clients` | Create OAuth2 client | +| `GET` | `/v1/admin/oauth/clients` | List OAuth2 clients | +| `DELETE` | `/v1/admin/oauth/clients/:clientId` | Delete OAuth2 client | ## JWT access tokens diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index c386d4a6..24646a44 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -14,9 +14,10 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(organization.New(store, organization.Config{ - PathPrefix: "/v1/auth", + PathPrefix: "/v1", })), ) ``` @@ -40,7 +41,7 @@ eng, err := authsome.NewEngine( ### Create organization -`POST /v1/auth/organizations` +`POST /v1/orgs` ```json { @@ -52,11 +53,11 @@ eng, err := authsome.NewEngine( ### Get organization -`GET /v1/auth/organizations/:orgId` +`GET /v1/orgs/:orgId` ### Update organization -`PUT /v1/auth/organizations/:orgId` +`PATCH /v1/orgs/:orgId` ```json { @@ -67,13 +68,13 @@ eng, err := authsome.NewEngine( ### Delete organization -`DELETE /v1/auth/organizations/:orgId` +`DELETE /v1/orgs/:orgId` ## Member management ### Add member -`POST /v1/auth/organizations/:orgId/members` +`POST /v1/orgs/:orgId/members` ```json { @@ -84,11 +85,11 @@ eng, err := authsome.NewEngine( ### List members -`GET /v1/auth/organizations/:orgId/members` +`GET /v1/orgs/:orgId/members` ### Update member role -`PUT /v1/auth/organizations/:orgId/members/:memberId` +`PATCH /v1/orgs/:orgId/members/:memberId` ```json {"role": "owner"} @@ -96,7 +97,7 @@ eng, err := authsome.NewEngine( ### Remove member -`DELETE /v1/auth/organizations/:orgId/members/:memberId` +`DELETE /v1/orgs/:orgId/members/:memberId` ## Team management @@ -104,7 +105,7 @@ Teams provide sub-grouping within organizations: ### Create team -`POST /v1/auth/organizations/:orgId/teams` +`POST /v1/orgs/:orgId/teams` ```json { @@ -113,13 +114,7 @@ Teams provide sub-grouping within organizations: } ``` -### Add team member - -`POST /v1/auth/organizations/:orgId/teams/:teamId/members` - -```json -{"user_id": "ausr_01j9..."} -``` +Teams can also be read, updated and deleted at `GET`, `PATCH` and `DELETE /v1/orgs/:orgId/teams/:teamId`. There's no HTTP route for team membership. ## Invitation system @@ -127,7 +122,7 @@ Invite users to join an organization by email: ### Send invitation -`POST /v1/auth/organizations/:orgId/invitations` +`POST /v1/orgs/:orgId/invitations` ```json { @@ -140,17 +135,15 @@ The engine creates an invitation record and, if the email plugin or notification ### Accept invitation -`POST /v1/auth/invitations/:token/accept` +`POST /v1/orgs/invitations/accept` The user clicks the invitation link, which resolves the token and adds them as a member with the specified role. ### List pending invitations -`GET /v1/auth/organizations/:orgId/invitations` - -### Revoke invitation +`GET /v1/orgs/:orgId/invitations` -`DELETE /v1/auth/organizations/:orgId/invitations/:invitationId` +Invitees can also decline with `POST /v1/orgs/invitations/decline`. There's no route to revoke an invitation. ## Organization-scoped roles diff --git a/docs/content/docs/plugins/overview.mdx b/docs/content/docs/plugins/overview.mdx index 54632ae8..6f001e71 100644 --- a/docs/content/docs/plugins/overview.mdx +++ b/docs/content/docs/plugins/overview.mdx @@ -3,7 +3,7 @@ title: Plugin Architecture description: How Authsome's plugin system extends the engine with authentication strategies, hooks, routes, and migrations. --- -Authsome is built around a composable plugin architecture. Every authentication method, lifecycle behavior, and integration point is implemented as a plugin that registers itself with the engine. The core engine provides session management, user storage, and the hook bus -- plugins add everything else. +Authsome is built around a composable plugin architecture. Every authentication method, lifecycle behavior, and integration point is implemented as a plugin that registers itself with the engine. The core engine provides session management, user storage, and the hook bus: plugins add everything else. ## The Plugin interface @@ -35,7 +35,7 @@ Plugins declare what they can do by implementing zero or more of the following i ## Hook interfaces -Plugins can subscribe to lifecycle events by implementing hook interfaces. **Before-hooks** can abort the operation by returning an error. **After-hooks** are fire-and-forget -- errors are logged but never block the pipeline. +Plugins can subscribe to lifecycle events by implementing hook interfaces. **Before-hooks** can abort the operation by returning an error. **After-hooks** are fire-and-forget: errors are logged but never block the pipeline. ### Auth event hooks @@ -70,7 +70,7 @@ Plugins can subscribe to lifecycle events by implementing hook interfaces. **Bef ## Plugin registry -The `plugin.Registry` is the core dispatch mechanism. It type-caches plugins at registration time so that emit calls iterate only over plugins implementing the relevant hook -- there is no runtime type assertion on the hot path. +The `plugin.Registry` is the core dispatch mechanism. It type-caches plugins at registration time so that emit calls iterate only over plugins implementing the relevant hook: there is no runtime type assertion on the hot path. ```go registry := plugin.NewRegistry(logger) @@ -86,11 +86,11 @@ err := registry.EmitBeforeSignUp(ctx, req) When you call `engine.Start()`, the following sequence executes: -1. **Registration** -- Plugins passed via `WithPlugin()` are registered with the plugin registry. Each plugin is type-asserted into all applicable hook caches. -2. **Migration** -- The engine collects migration groups from all `MigrationProvider` plugins and runs them alongside core schema migrations. -3. **Initialization** -- The registry calls `OnInit(ctx, engine)` on every plugin that implements `OnInit`. Plugins use this to capture store references, bridge handles, and other engine capabilities. -4. **Strategy registration** -- Plugins implementing `StrategyProvider` have their strategies auto-registered with the strategy registry. -5. **Route registration** -- Plugins implementing `RouteProvider` have their HTTP routes registered on the Forge router. +1. **Registration**: Plugins passed via `WithPlugin()` are registered with the plugin registry. Each plugin is type-asserted into all applicable hook caches. +2. **Migration**: The engine collects migration groups from all `MigrationProvider` plugins and runs them alongside core schema migrations. +3. **Initialization**: The registry calls `OnInit(ctx, engine)` on every plugin that implements `OnInit`. Plugins use this to capture store references, bridge handles, and other engine capabilities. +4. **Strategy registration**: Plugins implementing `StrategyProvider` have their strategies auto-registered with the strategy registry. +5. **Route registration**: Plugins implementing `RouteProvider` have their HTTP routes registered on the Forge router. ## Built-in plugins @@ -124,13 +124,14 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(password.New()), authsome.WithPlugin(mfa.New(mfa.Config{Issuer: "MyApp"})), authsome.WithPlugin(social.New(social.Config{ Providers: []social.Provider{ - social.NewGoogle(googleCfg), - social.NewGitHub(githubCfg), + social.NewGoogleProvider(googleCfg), + social.NewGitHubProvider(githubCfg), }, })), ) diff --git a/docs/content/docs/plugins/passkey.mdx b/docs/content/docs/plugins/passkey.mdx index 5c9bd524..fc373890 100644 --- a/docs/content/docs/plugins/passkey.mdx +++ b/docs/content/docs/plugins/passkey.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(passkey.New(passkey.Config{ RPDisplayName: "My App", @@ -49,7 +50,7 @@ The registration ceremony creates a new passkey credential for the authenticated ### Step 1: Begin registration -`POST /v1/auth/passkey/register/begin` +`POST /v1/passkeys/register/begin` The server generates a challenge and returns `PublicKeyCredentialCreationOptions` for the browser's `navigator.credentials.create()` API. @@ -73,7 +74,7 @@ The server generates a challenge and returns `PublicKeyCredentialCreationOptions ### Step 2: Complete registration -`POST /v1/auth/passkey/register/finish` +`POST /v1/passkeys/register/finish` The client sends the `AuthenticatorAttestationResponse` from the browser. The server validates the attestation, extracts the public key, and stores the credential. @@ -95,13 +96,13 @@ The authentication ceremony verifies the user's passkey credential. ### Step 1: Begin authentication -`POST /v1/auth/passkey/login/begin` +`POST /v1/passkeys/login/begin` Returns `PublicKeyCredentialRequestOptions` for `navigator.credentials.get()`. ### Step 2: Complete authentication -`POST /v1/auth/passkey/login/finish` +`POST /v1/passkeys/login/finish` The client sends the `AuthenticatorAssertionResponse`. The server verifies the signature against the stored public key, updates the sign count, and creates a session. @@ -109,7 +110,7 @@ The client sends the `AuthenticatorAssertionResponse`. The server verifies the s ### Resident keys (discoverable credentials) -When `residentKey` is set to `"required"` or `"preferred"`, the authenticator stores the credential internally. Users can authenticate without entering a username first -- the authenticator presents all stored credentials for the relying party. +When `residentKey` is set to `"required"` or `"preferred"`, the authenticator stores the credential internally. Users can authenticate without entering a username first: the authenticator presents all stored credentials for the relying party. ### Non-resident keys @@ -117,8 +118,8 @@ The server must provide a list of allowed credential IDs during authentication. ### Platform vs roaming authenticators -- **Platform authenticators** -- Built into the device (Touch ID, Face ID, Windows Hello). Use `authenticatorAttachment: "platform"` to prefer these. -- **Roaming authenticators** -- External security keys (YubiKey, SoloKeys). Use `authenticatorAttachment: "cross-platform"`. +- Platform authenticators: Built into the device (Touch ID, Face ID, Windows Hello). Use `authenticatorAttachment: "platform"` to prefer these. +- Roaming authenticators: External security keys (YubiKey, SoloKeys). Use `authenticatorAttachment: "cross-platform"`. ## Credential storage @@ -151,12 +152,12 @@ The passkey plugin implements `AuthMethodContributor`, returning one entry per r | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/passkey/register/begin` | Start passkey registration ceremony | -| `POST` | `/v1/auth/passkey/register/finish` | Complete passkey registration | -| `POST` | `/v1/auth/passkey/login/begin` | Start passkey authentication ceremony | -| `POST` | `/v1/auth/passkey/login/finish` | Complete passkey authentication | -| `GET` | `/v1/auth/passkey/credentials` | List user's registered passkeys | -| `DELETE` | `/v1/auth/passkey/credentials/:id` | Remove a registered passkey | +| `POST` | `/v1/passkeys/register/begin` | Start passkey registration ceremony | +| `POST` | `/v1/passkeys/register/finish` | Complete passkey registration | +| `POST` | `/v1/passkeys/login/begin` | Start passkey authentication ceremony | +| `POST` | `/v1/passkeys/login/finish` | Complete passkey authentication | +| `GET` | `/v1/passkeys` | List user's registered passkeys | +| `DELETE` | `/v1/passkeys/:credentialId` | Remove a registered passkey | ## Ceremony state management diff --git a/docs/content/docs/plugins/password.mdx b/docs/content/docs/plugins/password.mdx index 95be646b..9b810ae9 100644 --- a/docs/content/docs/plugins/password.mdx +++ b/docs/content/docs/plugins/password.mdx @@ -14,6 +14,7 @@ import ( ) 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{ @@ -52,14 +53,15 @@ password.New(password.Config{ ## Sign-up flow -`POST /v1/auth/signup` +`POST /v1/signup` ```json { "email": "alice@example.com", "password": "Secure!Pass99", "username": "alice", - "name": "Alice Liddell", + "first_name": "Alice", + "last_name": "Liddell", "app_id": "myapp" } ``` @@ -76,7 +78,7 @@ The engine performs these checks before creating the user: ## Sign-in flow -`POST /v1/auth/signin` +`POST /v1/signin` ```json { @@ -120,7 +122,7 @@ argon2id is memory-hard, making GPU brute-force attacks significantly more expen ### Transparent algorithm migration -When you change `Algorithm`, existing users are migrated automatically on their next successful sign-in. The engine detects the old hash format and re-hashes with the new algorithm -- no migration scripts needed. +When you change `Algorithm`, existing users are migrated automatically on their next successful sign-in. The engine detects the old hash format and re-hashes with the new algorithm: no migration scripts needed. ## Password policies @@ -152,7 +154,7 @@ Password: authsome.PasswordConfig{ -**Request reset**: `POST /v1/auth/forgot-password` +**Request reset**: `POST /v1/forgot-password` ```json {"email": "alice@example.com", "app_id": "myapp"} @@ -161,7 +163,7 @@ Password: authsome.PasswordConfig{ The engine creates a reset token with 1-hour expiry. The response is always `200 OK` regardless of whether the email exists, preventing email enumeration. -**Confirm reset**: `POST /v1/auth/reset-password` +**Confirm reset**: `POST /v1/reset-password` ```json { @@ -176,7 +178,7 @@ The engine validates the token, enforces the password policy, updates the hash, ## Change password flow -`POST /v1/auth/change-password` +`POST /v1/change-password` ```json { diff --git a/docs/content/docs/plugins/phone.mdx b/docs/content/docs/plugins/phone.mdx index 88f79efd..013b1ca9 100644 --- a/docs/content/docs/plugins/phone.mdx +++ b/docs/content/docs/plugins/phone.mdx @@ -14,6 +14,7 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithSMSSender(smsBridge), authsome.WithPlugin(phone.New(phone.Config{ @@ -39,7 +40,7 @@ func boolPtr(v bool) *bool { return &v } |-----------|---------| | `Plugin` | Base plugin identity (`"phone"`) | | `OnInit` | Captures store, SMS sender, ceremony store, logger, and session config resolver | -| `RouteProvider` | Registers `/v1/auth/phone/start` and `/verify` endpoints | +| `RouteProvider` | Registers `/v1/phone/start` and `/verify` endpoints | | `AuthMethodContributor` | Reports `"phone"` as a linked auth method if the user has a verified phone | ## Authentication flow @@ -48,7 +49,7 @@ func boolPtr(v bool) *bool { return &v } **Send OTP**: The user submits their phone number in E.164 format. -`POST /v1/auth/phone/start` +`POST /v1/phone/start` ```json { @@ -71,7 +72,7 @@ The plugin validates the phone number format (E.164: `+` followed by 7-15 digits **Verify OTP**: The user enters the received code. -`POST /v1/auth/phone/verify` +`POST /v1/phone/verify` ```json { @@ -172,5 +173,5 @@ The phone plugin implements `AuthMethodContributor`, reporting phone as a linked | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/phone/start` | Send OTP to phone number | -| `POST` | `/v1/auth/phone/verify` | Verify OTP and authenticate | +| `POST` | `/v1/phone/start` | Send OTP to phone number | +| `POST` | `/v1/phone/verify` | Verify OTP and authenticate | diff --git a/docs/content/docs/plugins/social.mdx b/docs/content/docs/plugins/social.mdx index 964344f6..e20d282f 100644 --- a/docs/content/docs/plugins/social.mdx +++ b/docs/content/docs/plugins/social.mdx @@ -14,19 +14,20 @@ import ( ) 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{ - social.NewGoogle(social.GoogleConfig{ + social.NewGoogleProvider(social.ProviderConfig{ ClientID: "your-client-id", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/social/google/callback", + RedirectURL: "https://example.com/v1/social/google/callback", Scopes: []string{"openid", "email", "profile"}, }), - social.NewGitHub(social.GitHubConfig{ + social.NewGitHubProvider(social.ProviderConfig{ ClientID: "your-client-id", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/social/github/callback", + RedirectURL: "https://example.com/v1/social/github/callback", }), }, SessionTokenTTL: 1 * time.Hour, @@ -40,6 +41,7 @@ eng, err := authsome.NewEngine( | Option | Type | Default | Description | |--------|------|---------|-------------| | `Providers` | `[]Provider` | `[]` | List of enabled OAuth providers | +| `Domain` | `string` | `""` | Public base URL. A provider without a `RedirectURL` gets `{Domain}/v1/social/{provider}/callback` | | `SessionTokenTTL` | `time.Duration` | `1h` | Lifetime of sessions created via social sign-in | | `SessionRefreshTTL` | `time.Duration` | `30d` | Lifetime of refresh tokens | @@ -49,20 +51,22 @@ eng, err := authsome.NewEngine( |-----------|---------| | `Plugin` | Base plugin identity (`"social"`) | | `OnInit` | Captures store, bridges, ceremony store, and session config resolver | -| `RouteProvider` | Registers `/v1/auth/social/:provider` and callback endpoints | +| `RouteProvider` | Registers `/v1/social/:provider` and callback endpoints | | `MigrationProvider` | Contributes the `oauth_connections` table | | `AuthMethodContributor` | Reports linked social accounts as auth methods | | `AuthMethodUnlinker` | Supports unlinking a social account from a user | ## Supported providers +The package has 22 constructors, all taking `social.ProviderConfig` (`ClientID`, `ClientSecret`, `RedirectURL`, `Scopes`): Amazon, Apple, Bitbucket, Discord, Dropbox, Facebook, GitHub, GitLab, Google, Instagram, Line, LinkedIn, Microsoft, Patreon, Pinterest, Slack, Spotify, Strava, Twitch, Twitter, Yahoo and Zoom (`social.NewGoogleProvider`, `social.NewGitHubProvider` and so on). + ### Google ```go -social.NewGoogle(social.GoogleConfig{ +social.NewGoogleProvider(social.ProviderConfig{ ClientID: "your-client-id.apps.googleusercontent.com", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/social/google/callback", + RedirectURL: "https://example.com/v1/social/google/callback", Scopes: []string{"openid", "email", "profile"}, }) ``` @@ -70,33 +74,31 @@ social.NewGoogle(social.GoogleConfig{ ### GitHub ```go -social.NewGitHub(social.GitHubConfig{ +social.NewGitHubProvider(social.ProviderConfig{ ClientID: "your-client-id", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/social/github/callback", + RedirectURL: "https://example.com/v1/social/github/callback", }) ``` ### Microsoft ```go -social.NewMicrosoft(social.MicrosoftConfig{ +social.NewMicrosoftProvider(social.ProviderConfig{ ClientID: "your-client-id", ClientSecret: "your-client-secret", - TenantID: "common", - RedirectURL: "https://example.com/v1/auth/social/microsoft/callback", + RedirectURL: "https://example.com/v1/social/microsoft/callback", }) +// Always uses the "common" tenant; there's no tenant option. ``` ### Apple ```go -social.NewApple(social.AppleConfig{ - ClientID: "your-service-id", - TeamID: "your-team-id", - KeyID: "your-key-id", - PrivateKey: applePrivateKeyPEM, - RedirectURL: "https://example.com/v1/auth/social/apple/callback", +social.NewAppleProvider(social.ProviderConfig{ + ClientID: "your-service-id", + ClientSecret: appleClientSecretJWT, // the signed client-secret JWT Apple expects; the provider doesn't mint it + RedirectURL: "https://example.com/v1/social/apple/callback", }) ``` @@ -122,7 +124,7 @@ A login started from a browser (the start request carries fetch metadata or an ` **Start OAuth flow**: The client calls the start endpoint to get the authorization URL. -`POST /v1/auth/social/:provider` +`POST /v1/social/:provider` **Response:** @@ -138,7 +140,7 @@ The plugin generates a cryptographic state parameter for CSRF protection and sto **Callback**: The provider redirects back with an authorization code. -`GET /v1/auth/social/:provider/callback?code=...&state=...` +`GET /v1/social/:provider/callback?code=...&state=...` The plugin validates the state parameter, exchanges the code for an access token, fetches the user profile from the provider, and either links to an existing user or creates a new one. @@ -180,12 +182,12 @@ The `CanUnlink` method checks whether the provider is managed by this plugin. Th | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/social/:provider` | Start OAuth flow, returns authorization URL | -| `GET` | `/v1/auth/social/:provider/callback` | OAuth callback, exchanges code for session | +| `POST` | `/v1/social/:provider` | Start OAuth flow, returns authorization URL | +| `GET` | `/v1/social/:provider/callback` | OAuth callback, exchanges code for session | ## Observability The plugin emits events for: -- `auth.social.signup` -- New user created via social login -- `auth.social.signin` -- Existing user signed in via social login +- `auth.social.signup`: New user created via social login +- `auth.social.signin`: Existing user signed in via social login diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index aff781ae..a1050f15 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -14,22 +14,18 @@ import ( ) eng, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithStore(store), authsome.WithPlugin(sso.New(sso.Config{ Providers: []sso.Provider{ - sso.NewOIDC(sso.OIDCConfig{ + sso.NewOIDCProvider(sso.OIDCConfig{ Name: "okta", Issuer: "https://mycompany.okta.com", ClientID: "your-client-id", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/sso/okta/callback", - }), - sso.NewSAML(sso.SAMLConfig{ - Name: "azure-ad", - MetadataURL: "https://login.microsoftonline.com/.../metadata", - ACSPath: "/v1/auth/sso/azure-ad/acs", - EntityID: "https://example.com", + RedirectURL: "https://example.com/v1/sso/okta/callback", }), + samlProvider, // from sso.NewSAMLProvider, which returns an error; see below }, SessionTokenTTL: 1 * time.Hour, SessionRefreshTTL: 30 * 24 * time.Hour, @@ -59,12 +55,12 @@ eng, err := authsome.NewEngine( OIDC providers (Okta, Auth0, Azure AD, Google Workspace) are configured with standard OpenID Connect discovery: ```go -sso.NewOIDC(sso.OIDCConfig{ +sso.NewOIDCProvider(sso.OIDCConfig{ Name: "okta", Issuer: "https://mycompany.okta.com", ClientID: "your-client-id", ClientSecret: "your-client-secret", - RedirectURL: "https://example.com/v1/auth/sso/okta/callback", + RedirectURL: "https://example.com/v1/sso/okta/callback", Scopes: []string{"openid", "email", "profile"}, }) ``` @@ -78,15 +74,19 @@ Every OIDC login carries a `nonce` and an S256 PKCE challenge. The plugin keeps SAML 2.0 providers (Azure AD, ADFS, OneLogin) use XML metadata for configuration: ```go -sso.NewSAML(sso.SAMLConfig{ +samlProvider, err := sso.NewSAMLProvider(sso.SAMLConfig{ Name: "azure-ad", MetadataURL: "https://login.microsoftonline.com/.../federationmetadata/2007-06/federationmetadata.xml", - ACSPath: "/v1/auth/sso/azure-ad/acs", EntityID: "https://example.com", - Certificate: samlCertPEM, + ACSURL: "https://example.com/v1/sso/azure-ad/acs", }) +if err != nil { + log.Fatal(err) +} ``` +Instead of `MetadataURL` you can pass the IdP's metadata inline (`IDPMetadataXML`) or its SSO URL and certificate (`IDPSSOURL`, `IDPCertificatePEM`). `SPCertificatePEM`, `SPPrivateKeyPEM` and `SignRequests` sign the AuthnRequests, `AllowIDPInitiated` accepts IdP-initiated logins, and `AttributeMap` maps assertion attributes to user fields. + The ACS (Assertion Consumer Service) endpoint receives the SAML response from the IdP and extracts the user identity. ## SSO authentication flow @@ -97,7 +97,7 @@ The ACS (Assertion Consumer Service) endpoint receives the SAML response from th **Start login**: The client requests the SSO login URL. -`POST /v1/auth/sso/:provider/login` +`POST /v1/sso/:provider/login` **Response:** @@ -114,8 +114,8 @@ The ACS (Assertion Consumer Service) endpoint receives the SAML response from th **Callback**: For OIDC, the callback exchanges the code for tokens. For SAML, the ACS endpoint processes the SAML response. -`POST /v1/auth/sso/:provider/callback` (OIDC) -`POST /v1/auth/sso/:provider/acs` (SAML) +`POST /v1/sso/:provider/callback` (OIDC) +`POST /v1/sso/:provider/acs` (SAML) **Response:** @@ -166,13 +166,13 @@ Both `allowed_domains` and `trusted_federation` are set through the admin connec | Method | Path | Description | |--------|------|-------------| -| `POST` | `/v1/auth/sso/:provider/login` | Start SSO login flow, returns IdP login URL | -| `POST` | `/v1/auth/sso/:provider/callback` | OIDC callback endpoint | -| `POST` | `/v1/auth/sso/:provider/acs` | SAML ACS (Assertion Consumer Service) endpoint | +| `POST` | `/v1/sso/:provider/login` | Start SSO login flow, returns IdP login URL | +| `POST` | `/v1/sso/:provider/callback` | OIDC callback endpoint | +| `POST` | `/v1/sso/:provider/acs` | SAML ACS (Assertion Consumer Service) endpoint | ## Observability The plugin emits events for: -- `auth.sso.signup` -- New user created via SSO -- `auth.sso.signin` -- Existing user signed in via SSO +- `auth.sso.signup`: New user created via SSO +- `auth.sso.signin`: Existing user signed in via SSO diff --git a/docs/content/docs/security/data-retention.mdx b/docs/content/docs/security/data-retention.mdx index 28b30f0f..7109f87a 100644 --- a/docs/content/docs/security/data-retention.mdx +++ b/docs/content/docs/security/data-retention.mdx @@ -7,7 +7,7 @@ Authsome keeps as little as it still needs. Deleting a user erases what every pl ## Deleting a user -`DELETE /v1/auth/me` (self-service) and the admin deletion both run the same erasure: +`DELETE /v1/me` (self-service) and the admin deletion both run the same erasure: 1. Every delegation the user is party to, as subject or actor, is revoked. 2. The user's devices are removed. diff --git a/docs/content/docs/security/rate-limiting.mdx b/docs/content/docs/security/rate-limiting.mdx index af7603e0..0ae05b01 100644 --- a/docs/content/docs/security/rate-limiting.mdx +++ b/docs/content/docs/security/rate-limiting.mdx @@ -40,8 +40,8 @@ authsome: | Field | Default | Route | |-------|---------|-------| -| `SignInLimit` | 5 | `/v1/auth/signin` and the identifier budget behind it | -| `SignUpLimit` | 3 | `/v1/auth/signup` | +| `SignInLimit` | 5 | `/v1/signin` and the identifier budget behind it | +| `SignUpLimit` | 3 | `/v1/signup` | | `RefreshLimit` | 10 | `/v1/refresh` | | `IntrospectLimit` | 20 | `/v1/introspect` | | `ForgotPasswordLimit` | 3 | password reset requests | @@ -71,6 +71,7 @@ The engine wires `ratelimit.NewKVLimiter(store)` over the store's key-value tabl import "github.com/xraph/authsome/ratelimit" engine, err := authsome.NewEngine( + // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) authsome.WithRateLimiter(ratelimit.NewMemoryLimiter()), // ... ) diff --git a/docs/content/docs/stores/memory.mdx b/docs/content/docs/stores/memory.mdx index 1e22c2a5..457b8443 100644 --- a/docs/content/docs/stores/memory.mdx +++ b/docs/content/docs/stores/memory.mdx @@ -3,7 +3,7 @@ title: Memory Store description: In-memory store for unit tests, integration tests, and local development without a database. --- -The memory store (`store/memory`) is a fully in-memory implementation of `store.Store`. All data is held in Go maps protected by a `sync.RWMutex`, making it safe for concurrent access. No external dependencies or configuration are required — creating a store is a single function call. +The memory store (`store/memory`) is a fully in-memory implementation of `store.Store`. Data lives in Go maps behind a mutex, so it's safe for concurrent use. It needs no configuration and loses everything when the process exits. ## Installation @@ -19,78 +19,25 @@ import "github.com/xraph/authsome/store/memory" memStore := memory.New() ``` -`memory.New()` returns a `*memory.Store` with all internal maps initialised and ready to use. The zero value is not valid — always use `New()`. +`memory.New()` returns a ready `*memory.Store`. The zero value isn't usable, so always call `New()`. ## Wiring into the engine ```go -package main - -import ( - "context" - "log" - "net/http" - "time" - - "github.com/xraph/authsome" - "github.com/xraph/authsome/plugins/password" - "github.com/xraph/authsome/store/memory" +eng, err := authsome.NewEngine( + authsome.WithStore(memory.New()), + authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithWarden(wardenEngine), + authsome.WithDisableMigrate(), // nothing to migrate + authsome.WithPlugin(password.New()), ) - -func main() { - ctx := context.Background() - - // In-memory store: no database required. - memStore := memory.New() - - eng, err := authsome.New( - authsome.WithStore(memStore), - authsome.WithPlugin(password.New()), - authsome.WithConfig(authsome.Config{ - AppID: "testapp", - BasePath: "/v1/auth", - Session: authsome.SessionConfig{ - TokenTTL: 1 * time.Hour, - RefreshTokenTTL: 24 * time.Hour, - }, - }), - ) - if err != nil { - log.Fatal(err) - } - defer eng.Stop(ctx) - - if err := eng.Start(ctx); err != nil { - log.Fatal(err) - } - - mux := http.NewServeMux() - eng.RegisterRoutes(mux) - log.Fatal(http.ListenAndServe(":8080", mux)) -} ``` +`bridge.NewMemoryChronicle()` is an in-memory audit trail that suits tests. [Getting Started](/docs/getting-started) runs a full server on this store. + ## Implemented interfaces -The memory store satisfies every interface in the `store.Store` composite: - -| Interface | Internal map | -|-----------|-------------| -| `user.Store` | `map[string]*user.User` (keyed by TypeID string) | -| `session.Store` | `map[string]*session.Session` (keyed by TypeID string) | -| `account.Store` | `map[string]*account.Verification`, `map[string]*account.PasswordReset` (keyed by token) | -| `app.Store` | `map[string]*app.App` (keyed by TypeID string) | -| `organization.Store` | `map[string]*organization.Organization`, `map[string]*organization.Member`, `map[string]*organization.Invitation`, `map[string]*organization.Team` | -| `device.Store` | `map[string]*device.Device` (keyed by TypeID string) | -| `webhook.Store` | `map[string]*webhook.Webhook` (keyed by TypeID string) | -| `notification.Store` | `map[string]*notification.Notification` (keyed by TypeID string) | -| `apikey.Store` | `map[string]*apikey.APIKey` (keyed by TypeID string) | -| `environment.Store` | `map[string]*environment.Environment` (keyed by TypeID string) | -| `formconfig.Store` | `map[string]*formconfig.FormConfig` (keyed by TypeID string) | -| `formconfig.BrandingStore` | `map[string]*formconfig.BrandingConfig` (keyed by TypeID string) | -| `appsessionconfig.Store` | `map[string]*appsessionconfig.Config` (keyed by AppID string) | - -The compile-time assertion at the top of the package guarantees this: +The store implements all of `store.Store` (see [Store Backends](/docs/stores/overview)), checked at compile time: ```go var _ store.Store = (*Store)(nil) @@ -98,9 +45,7 @@ var _ store.Store = (*Store)(nil) ## Concurrency safety -All methods acquire either a read lock (`sync.RWMutex.RLock`) or a write lock (`sync.RWMutex.Lock`) before accessing internal maps. Read operations (all `Get*` and `List*` methods) acquire a read lock, allowing multiple concurrent readers. Write operations acquire an exclusive lock. - -This makes the memory store safe for use in concurrent test scenarios where multiple goroutines sign in and sign out simultaneously. +Reads take a read lock and writes take the write lock, so tests that sign users in and out from several goroutines are fine. ## Lifecycle methods @@ -114,156 +59,100 @@ The memory store has no schema to create and no connection to verify, so all lif ## Lookup behaviour -### User lookups - -The memory store scans all users linearly for lookups by email, phone, and username. This is acceptable for test datasets but would not scale to production volumes: - -```go -func (s *Store) GetUserByEmail(_ context.Context, appID id.AppID, email string) (*user.User, error) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, u := range s.users { - if u.AppID.String() == appID.String() && u.Email == email { - return u, nil - } - } - return nil, store.ErrNotFound -} -``` - -In production stores (PostgreSQL, SQLite, MongoDB), this lookup uses an indexed column and is O(log n). - -### Session token lookups - -Session tokens and refresh tokens are also looked up by linear scan: - -```go -func (s *Store) GetSessionByToken(_ context.Context, token string) (*session.Session, error) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, sess := range s.sessions { - if sess.Token == token { - return sess, nil - } - } - return nil, store.ErrNotFound -} -``` - -For test suites with hundreds of sessions, this remains fast. For performance benchmarking of the authentication hot path, use a real database backend. - -## RBAC in the memory store - -The memory store also implements `rbac.Store` and includes a full in-memory RBAC evaluator with hierarchical role support. `HasPermission` walks the role parent chain (up to depth 20 to prevent infinite loops) to evaluate inherited permissions: - -```go -// HasPermission checks if userID has action+resource permission, -// including permissions inherited from parent roles. -func (s *Store) HasPermission(ctx context.Context, userID string, action, resource string) (bool, error) -``` - -This allows you to test complex RBAC scenarios — role hierarchies, wildcard permissions (`*`), resource-level checks — without any database setup. +Lookups by email, username or token scan the maps. That's fine for test data, but don't benchmark the auth path against this store. ## Error handling -The memory store returns `store.ErrNotFound` for all "not found" cases, matching the behaviour of all production store backends: +The memory store returns `store.ErrNotFound` when something doesn't exist, like the other backends: ```go var ErrNotFound = errors.New("authsome: not found") ``` -Use `errors.Is(err, store.ErrNotFound)` to detect missing entities consistently across all backends. +Check for it with `errors.Is(err, store.ErrNotFound)`. ## Using the memory store in tests +This test passes as written: + ```go package auth_test import ( - "context" + "encoding/json" "testing" - "time" "github.com/stretchr/testify/require" + "github.com/xraph/warden" + wmemory "github.com/xraph/warden/store/memory" + "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/bridge" "github.com/xraph/authsome/plugins/password" + "github.com/xraph/authsome/settings" "github.com/xraph/authsome/store/memory" ) func newTestEngine(t *testing.T) *authsome.Engine { t.Helper() + ctx := t.Context() - memStore := memory.New() + authz, err := warden.NewEngine(warden.WithStore(wmemory.New())) + require.NoError(t, err) - eng, err := authsome.New( - authsome.WithStore(memStore), + eng, err := authsome.NewEngine( + authsome.WithStore(memory.New()), + authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithWarden(authz), + authsome.WithDisableMigrate(), authsome.WithPlugin(password.New()), - authsome.WithConfig(authsome.Config{ - AppID: "testapp", - BasePath: "/v1/auth", - Session: authsome.SessionConfig{ - TokenTTL: 5 * time.Minute, - RefreshTokenTTL: 1 * time.Hour, - }, - Password: authsome.PasswordConfig{ - MinLength: 12, - }, - }), + authsome.WithBootstrap(authsome.WithBootstrapAppName("Test")), ) require.NoError(t, err) - - ctx := context.Background() require.NoError(t, eng.Start(ctx)) - t.Cleanup(func() { eng.Stop(ctx) }) + t.Cleanup(func() { _ = eng.Stop(ctx) }) + + // Sign-in needs a verified email by default; switch that off for tests. + require.NoError(t, eng.Settings().Set(ctx, "auth.require_email_verification", + json.RawMessage(`false`), settings.ScopeGlobal, "", "", "", "test")) return eng } func TestSignUpAndSignIn(t *testing.T) { eng := newTestEngine(t) - ctx := context.Background() + ctx := t.Context() + appID := eng.PlatformAppID() - // Sign up - signupResult, err := eng.SignUp(ctx, &authsome.SignUpRequest{ + u, sess, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, Email: "alice@example.com", - Password: "securepassword123", + Password: "SecureP@ss123", }) require.NoError(t, err) - require.NotEmpty(t, signupResult.Token) + require.NotEmpty(t, sess.Token) - // Sign in - signinResult, err := eng.SignIn(ctx, &authsome.SignInRequest{ + u2, _, err := eng.SignIn(ctx, &account.SignInRequest{ + AppID: appID, Email: "alice@example.com", - Password: "securepassword123", + Password: "SecureP@ss123", }) require.NoError(t, err) - require.Equal(t, signupResult.User.ID, signinResult.User.ID) + require.Equal(t, u.ID, u2.ID) } ``` -## Swapping to a production backend - -The memory store implements the same `store.Store` interface as all production backends. Switching is a single-line change: +Sign-in refuses unverified emails by default, so the helper switches `auth.require_email_verification` off. Rate limiting and lockout are on too; a test that signs in many times can replace them with `eng.SetRateLimiter(ratelimit.NewNoopLimiter())` and `eng.SetLockoutTracker(lockout.NewNoopTracker())`. -```go -// Testing -memStore := memory.New() - -// Production (PostgreSQL) -pgStore := postgres.New(grove.Open(pgdriver.New(os.Getenv("DATABASE_URL")))) -``` +## Swapping to a production backend -Pass either value to `authsome.WithStore(...)` — no other code changes are required. +Pass a different store to `authsome.WithStore`, add `WithDriverName`, and drop `WithDisableMigrate()`. See [PostgreSQL](/docs/stores/postgres). ## When to use -- **Unit tests** — No setup, no teardown, zero dependencies. Every test starts with a clean empty store. -- **Integration tests** — Test full request/response cycles without a database. -- **CI pipelines** — Fast, deterministic, no database container required. -- **Local prototyping** — Explore Authsome's API without configuring any infrastructure. +Use it for unit and integration tests, CI without a database container, and quick local experiments. ## When not to use -- **Production** — All data is lost on process restart. There is no persistence. -- **Multi-instance deployments** — Each process has an isolated store. Sign-in on instance A cannot be validated on instance B. -- **Performance benchmarking** — Linear scans do not reflect production database performance characteristics. +Don't use it in production (a restart loses everything) or with more than one instance (each process has its own data, so a session from one isn't valid on another). diff --git a/docs/content/docs/stores/mongodb.mdx b/docs/content/docs/stores/mongodb.mdx index 02a3322e..ccdd4a88 100644 --- a/docs/content/docs/stores/mongodb.mdx +++ b/docs/content/docs/stores/mongodb.mdx @@ -3,7 +3,7 @@ title: MongoDB description: MongoDB backend for teams operating a document-oriented data platform. --- -The MongoDB store (`store/mongo`) provides a document-oriented backend using Grove ORM with `mongodriver`. It implements the full `store.Store` composite interface and maps each Authsome sub-store to a dedicated MongoDB collection with automatic index creation. +The MongoDB store (`store/mongo`) provides a document-oriented backend using Grove ORM with `mongodriver`. It implements `store.Store`, keeps each kind of record in its own collection and creates the indexes during migration. ## Installation @@ -17,23 +17,32 @@ go get github.com/xraph/grove/drivers/mongodriver ```go import ( + "log" "os" "github.com/xraph/grove" "github.com/xraph/grove/drivers/mongodriver" + "github.com/xraph/authsome/store/mongo" ) -// Open a Grove DB backed by MongoDB. -db := grove.Open(mongodriver.New( - mongodriver.WithURI(os.Getenv("MONGODB_URI")), +mdb := mongodriver.New() +if err := mdb.Open(ctx, os.Getenv("MONGODB_URI"), mongodriver.WithDatabase("authsome"), -)) + mongodriver.WithMaxPoolSize(50), +); err != nil { + log.Fatal(err) +} +db, err := grove.Open(mdb) +if err != nil { + log.Fatal(err) +} -// Create the Authsome store. mgoStore := mongo.New(db) ``` +Without `WithDatabase`, the driver takes the database name from the URI. Other options include `WithMinPoolSize`, `WithConnectTimeout`, `WithServerSelectionTimeout` and `WithTimeout`. TLS and Atlas settings go in the URI. + ### Connection string format Standard MongoDB connection strings are supported: @@ -44,74 +53,31 @@ mongodb+srv://user:password@cluster.mongodb.net/authsome?retryWrites=true&w=majo mongodb://localhost:27017/authsome?authSource=admin ``` -For MongoDB Atlas: - -```go -db := grove.Open(mongodriver.New( - mongodriver.WithURI(os.Getenv("MONGODB_ATLAS_URI")), - mongodriver.WithDatabase("authsome"), - mongodriver.WithTLS(true), -)) -``` - ## Wiring into the engine ```go -package main - -import ( - "context" - "log" - "net/http" - "os" - "time" - - "github.com/xraph/authsome" - "github.com/xraph/authsome/plugins/password" - "github.com/xraph/authsome/store/mongo" - "github.com/xraph/grove" - "github.com/xraph/grove/drivers/mongodriver" +eng, err := authsome.NewEngine( + authsome.WithStore(mgoStore), + authsome.WithDriverName("mongo"), + authsome.WithDB(db), + authsome.WithChronicle(chronicleBridge), + authsome.WithWarden(wardenEngine), + authsome.WithPlugin(password.New()), ) - -func main() { - ctx := context.Background() - - db := grove.Open(mongodriver.New( - mongodriver.WithURI(os.Getenv("MONGODB_URI")), - mongodriver.WithDatabase("authsome"), - )) - mgoStore := mongo.New(db) - - eng, err := authsome.New( - authsome.WithStore(mgoStore), - 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, - }, - }), - ) - if err != nil { - log.Fatal(err) - } - defer eng.Stop(ctx) - - if err := eng.Start(ctx); err != nil { - log.Fatal(err) - } - - mux := http.NewServeMux() - eng.RegisterRoutes(mux) - log.Fatal(http.ListenAndServe(":8080", mux)) +if err != nil { + log.Fatal(err) } +if err := eng.Start(ctx); err != nil { + log.Fatal(err) +} +defer eng.Stop(ctx) ``` +`WithDB(db)` lets plugins with their own stores (mfa, sso and others) use MongoDB instead of memory. Serve the API the way [Getting Started](/docs/getting-started) does. Most plugins only ship PostgreSQL and SQLite migration groups, so on MongoDB the store's own migrations are what create the collections and indexes. + ## Migrations -The MongoDB store uses Grove's migration orchestrator to create collections and their required indexes when `eng.Start(ctx)` is called. Unlike relational databases, MongoDB is schema-less for field data — migrations only create indexes. No DDL is required for the collection structure itself. +`eng.Start(ctx)` runs the store's migrations through Grove's orchestrator, creating collections and indexes. There's no schema for the documents themselves. Migration history is tracked in a `grove_migrations` collection within the same database. @@ -129,23 +95,18 @@ All Authsome collections use the `authsome_` prefix: | Collection | Purpose | |------------|---------| -| `authsome_users` | User documents | -| `authsome_sessions` | Active session documents | -| `authsome_verifications` | Email verification token documents | -| `authsome_password_resets` | Password reset token documents | -| `authsome_apps` | Application registration documents | -| `authsome_organizations` | Organization documents | -| `authsome_members` | Membership documents | -| `authsome_invitations` | Invitation documents | -| `authsome_teams` | Team documents | -| `authsome_devices` | Device fingerprint documents | -| `authsome_webhooks` | Webhook endpoint documents | -| `authsome_notifications` | Notification queue documents | -| `authsome_api_keys` | API key documents | -| `authsome_environments` | Environment documents | -| `authsome_form_configs` | Form configuration documents | -| `authsome_branding_configs` | Branding configuration documents | -| `authsome_app_session_configs` | Per-app session config documents | +| `authsome_apps`, `authsome_environments` | Apps and their environments | +| `authsome_users`, `authsome_user_emails` | Users and their addresses | +| `authsome_sessions`, `authsome_revoked_refresh_tokens` | Sessions and refresh-token replay records | +| `authsome_verifications`, `authsome_password_resets` | Verification and reset tokens | +| `authsome_organizations`, `authsome_members`, `authsome_invitations`, `authsome_teams` | Organizations | +| `authsome_devices` | Devices | +| `authsome_webhooks`, `authsome_notifications` | Webhooks and notifications | +| `authsome_api_keys`, `authsome_service_accounts` | API keys and service accounts | +| `authsome_delegations` | Delegations | +| `authsome_form_configs`, `authsome_branding_configs` | Dynamic forms and branding | +| `authsome_app_session_configs` | Per-app session settings | +| `authsome_kv` | Key-value data | ## Indexes @@ -153,31 +114,31 @@ The migration system creates the following indexes automatically: ### Users -| Index | Fields | Type | -|-------|--------|------| -| Unique | `app_id, email` | Compound unique | -| Unique (sparse) | `app_id, phone` | Compound unique, sparse | -| Unique (sparse) | `app_id, username` | Compound unique, sparse | +| Fields | Type | +|--------|------| +| `app_id, email` | Unique | +| `app_id, username` | Unique, partial (only documents that have a username) | +| `app_id, created_at` | Standard | ### Sessions -| Index | Fields | Type | -|-------|--------|------| -| Unique | `token` | Unique | -| Unique | `refresh_token` | Unique | -| Standard | `user_id` | Standard | -| TTL | `expires_at` | TTL index for automatic expiry | +| Fields | Type | +|--------|------| +| `token` | Unique | +| `refresh_token` | Unique | +| `user_id, created_at` | Standard | +| `expires_at` | Standard. It isn't a TTL index, so expired sessions stay until something deletes them | ### API Keys -| Index | Fields | Type | -|-------|--------|------| -| Standard | `app_id, key_prefix` | Compound | -| Standard | `app_id, user_id` | Compound | +| Fields | Type | +|--------|------| +| `app_id, key_prefix` | Unique | +| `app_id, created_at` and `app_id, user_id, created_at` | Standard | ### Other collections -All collections are indexed on their primary `id` field. Lookup-heavy collections (verifications, password resets) are indexed on their `token` fields for O(log n) lookups. +Verification, reset and invitation tokens have unique indexes on `token`. Organizations and teams are unique on their slug within the parent, and members on `user_id, org_id`. ## BSON serialization @@ -200,19 +161,14 @@ The BSON field names use snake_case matching Authsome's JSON field names: | Method | Behaviour | |--------|-----------| -| `Migrate(ctx, extraGroups...)` | Creates collections and indexes via the Grove orchestrator. Idempotent. | -| `Ping(ctx)` | Calls the MongoDB `ping` command to verify connectivity. | -| `Close()` | Disconnects the MongoDB client. | +| `Migrate(ctx, extraGroups...)` | Creates collections and indexes through the Grove orchestrator. Idempotent | +| `Ping(ctx)` | Checks connectivity | +| `Close()` | Closes the `*grove.DB` | ## When to use -- **Existing MongoDB clusters** — If your organisation already operates MongoDB (Atlas or self-hosted), Authsome can share the same cluster. -- **Flexible schema evolution** — MongoDB's schema-less approach means adding fields to Authsome entities does not require ALTER TABLE statements. -- **Document-oriented queries** — If your application already queries MongoDB and you want authentication data co-located with application data. -- **Horizontal sharding** — MongoDB's built-in sharding capabilities can distribute Authsome data across multiple shards for very large user bases. +Use it when your team already runs MongoDB (Atlas or self-hosted) and wants Authsome on the same cluster. ## When not to use -- **New projects without existing MongoDB** — PostgreSQL is simpler to operate and has better tooling for relational data like RBAC hierarchies. -- **Complex relational queries** — Organisation hierarchy and RBAC role inheritance queries are more efficient in PostgreSQL with proper join support. -- **Transactions across collections** — MongoDB multi-document transactions have higher overhead than PostgreSQL transactions. +On a new project with no MongoDB already in place, PostgreSQL is simpler to run and gets every plugin's migrations. diff --git a/docs/content/docs/stores/overview.mdx b/docs/content/docs/stores/overview.mdx index 076a60a5..a13f2de7 100644 --- a/docs/content/docs/stores/overview.mdx +++ b/docs/content/docs/stores/overview.mdx @@ -3,11 +3,11 @@ title: Store Backends description: How Authsome persists authentication data across PostgreSQL, SQLite, MongoDB, and Memory backends. --- -Authsome separates its persistence layer behind a single composite interface — `store.Store` — defined in `github.com/xraph/authsome/store`. Every backend (PostgreSQL, SQLite, MongoDB, and the in-memory test store) implements this interface in full. You configure the backend once at engine startup and never touch it again; every plugin, service, and strategy reads and writes through the same interface. +Authsome keeps persistence behind one composite interface, `store.Store` in `github.com/xraph/authsome/store`. The PostgreSQL, SQLite, MongoDB and in-memory backends all implement it. You pick the backend when you build the engine, and the engine and its plugins go through that interface from then on. ## The composite store interface -The `store.Store` interface in `github.com/xraph/authsome/store` composes 13 sub-store interfaces plus three lifecycle methods: +`store.Store` embeds the domain stores plus three lifecycle methods. Abridged: ```go package store @@ -45,6 +45,14 @@ type Store interface { 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 @@ -71,6 +79,10 @@ Each sub-store covers one Authsome domain. The table below summarises what each | `formconfig.Store` | `github.com/xraph/authsome/formconfig` | Dynamic sign-up form configuration | | `formconfig.BrandingStore` | `github.com/xraph/authsome/formconfig` | Per-org branding configuration | | `appsessionconfig.Store` | `github.com/xraph/authsome/appsessionconfig` | Per-app session token configuration overrides | +| `appclientconfig.Store` | `github.com/xraph/authsome/appclientconfig` | Per-app client configuration | +| `settings.Store` | `github.com/xraph/authsome/settings` | Dynamic settings | +| `serviceaccount.Store`, `principal.Store` | `github.com/xraph/authsome/serviceaccount`, `.../principal` | Service accounts and principals | +| `KV`, `Retention`, `Paged`, `LegacyTokenHasher` | `github.com/xraph/authsome/store` | Key-value data, retention sweeps, paged listing, legacy token lookups | ## Lifecycle methods @@ -82,60 +94,61 @@ Every backend implements three lifecycle methods: | `Ping` | `Ping(ctx context.Context) error` | Verifies database connectivity. Useful for health checks and readiness probes. | | `Close` | `Close() error` | Releases all database connections and frees resources. | -The `Migrate` method accepts variadic `*migrate.Group` arguments from the Grove ORM migration system (`github.com/xraph/grove/migrate`). When the Authsome engine starts, it collects migration groups from all registered plugins and passes them to `Migrate` in a single call. This guarantees that core and plugin tables are always created together in the correct order. +When the engine starts it asks every plugin that implements `plugin.MigrationProvider` for its groups (`MigrationGroups(driverName)`, with the name you set through `WithDriverName`), and passes them to `Migrate` together with the core group. ## Migration system -Authsome's migration system is built on Grove ORM's `migrate.Group` and `migrate.Orchestrator`. Each backend registers a `Migrations` variable — a `*migrate.Group` — that describes all the model-level schema operations (create table, add index, etc.). +Migrations are Grove `migrate.Group` values run by `migrate.Orchestrator`. Each backend has a core group (`postgres.Migrations`, for example). On `eng.Start(ctx)`, unless you passed `WithDisableMigrate()`: -At engine start, the sequence is: +1. The engine collects the plugins' groups for the configured driver name. +2. It calls `store.Migrate(ctx, pluginGroups...)`. +3. The orchestrator records applied migrations in `grove_migrations` and skips them on later runs, so calling `Start` on every boot is safe. -1. The engine calls `eng.Start(ctx)`. -2. Each registered plugin is asked for its `MigrationGroup() *migrate.Group`. -3. All plugin groups plus the core group are passed to `store.Migrate(ctx, pluginGroups...)`. -4. The Grove orchestrator creates a `migrate_history` tracking table (if not present), then executes any pending migrations. -5. Already-applied migrations are skipped. The orchestrator is idempotent and safe to call on every startup. - -```go -// Engine calls this internally: -eng.store.Migrate(ctx, - passwordPlugin.MigrationGroup(), - socialPlugin.MigrationGroup(), - mfaPlugin.MigrationGroup(), -) -``` - -You should never need to call `Migrate` yourself — `eng.Start(ctx)` handles it. However, you can call it directly if you manage schema evolution outside the engine lifecycle. +`eng.EnsureMigrated(ctx)` runs the same steps without starting the engine, for a separate migration step. ## Choosing a backend | Backend | When to use | |---------|-------------| -| **PostgreSQL** | Production. ACID-compliant, connection-pooled, supports all Authsome features. Recommended default. | -| **SQLite** | Single-process deployments, local development, CLI tools, embedded applications. | -| **MongoDB** | Document-oriented workloads, teams already operating a Mongo cluster, flexible schema migrations. | -| **Memory** | Unit tests, integration tests, CI pipelines with no external database. | +| PostgreSQL | Production. The default choice | +| SQLite | Single-process deployments, local development, CLI tools, embedded applications | +| MongoDB | Teams already running a MongoDB cluster | +| Memory | Unit tests, CI and demos. Nothing survives a restart | ## Wiring a store into the engine ```go import ( - "github.com/xraph/authsome" - "github.com/xraph/authsome/store/postgres" + "log" + "os" + "github.com/xraph/grove" "github.com/xraph/grove/drivers/pgdriver" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/store/postgres" ) -db := grove.Open(pgdriver.New(os.Getenv("DATABASE_URL"))) -pgStore := postgres.New(db) +pg := pgdriver.New() +if err := pg.Open(ctx, os.Getenv("DATABASE_URL")); err != nil { + log.Fatal(err) +} +db, err := grove.Open(pg) +if err != nil { + log.Fatal(err) +} -eng, err := authsome.New( - authsome.WithStore(pgStore), - // ... other options +eng, err := authsome.NewEngine( + authsome.WithStore(postgres.New(db)), + authsome.WithDriverName("pg"), + authsome.WithDB(db), + authsome.WithChronicle(chronicleBridge), + authsome.WithWarden(wardenEngine), + // ... plugins and other options ) ``` -The store is the only required option — without `WithStore`, the engine returns an error on `New`. +`NewEngine` returns an error when the store, Chronicle or Warden is missing. ## Compile-time interface checks @@ -150,7 +163,7 @@ If you implement a custom store and miss any method, the build fails immediately ## Where to go next - + diff --git a/docs/content/docs/stores/postgres.mdx b/docs/content/docs/stores/postgres.mdx index a6295b19..4850cc33 100644 --- a/docs/content/docs/stores/postgres.mdx +++ b/docs/content/docs/stores/postgres.mdx @@ -3,7 +3,7 @@ title: PostgreSQL description: Production-grade PostgreSQL backend using Grove ORM with pgdriver. --- -The PostgreSQL store (`store/postgres`) is the recommended backend for all production Authsome deployments. It uses the Grove ORM with `pgdriver` for high-performance, pooled connections and ships with programmatic migrations that create all required tables automatically when `eng.Start(ctx)` is called. +The PostgreSQL store (`store/postgres`) is the backend to run in production. It sits on a Grove database opened with `pgdriver`, and `eng.Start(ctx)` creates its tables through Grove migrations. ## Installation @@ -17,21 +17,33 @@ go get github.com/xraph/grove/drivers/pgdriver ```go import ( + "log" "os" + "time" "github.com/xraph/grove" + "github.com/xraph/grove/driver" "github.com/xraph/grove/drivers/pgdriver" + "github.com/xraph/authsome/store/postgres" ) -// Open a Grove DB backed by PostgreSQL. -db := grove.Open(pgdriver.New(os.Getenv("DATABASE_URL"))) +pg := pgdriver.New() +if err := pg.Open(ctx, os.Getenv("DATABASE_URL"), + driver.WithPoolSize(25), + driver.WithMaxConnLifetime(5*time.Minute), +); err != nil { + log.Fatal(err) +} +db, err := grove.Open(pg) +if err != nil { + log.Fatal(err) +} -// Create the Authsome store. pgStore := postgres.New(db) ``` -`postgres.New` accepts a `*grove.DB` and returns a `*postgres.Store` that satisfies the full `store.Store` interface. The `pgdriver.New` constructor accepts a DSN string or `pgdriver.Option` functional options. +`postgres.New` takes a `*grove.DB` opened on the PostgreSQL driver and returns a `*postgres.Store` that implements `store.Store`. The pool options (`WithPoolSize`, `WithMinConns`, `WithMaxConnLifetime`, `WithMaxConnIdleTime`, `WithQueryTimeout`) come from `github.com/xraph/grove/driver`. ### Connection string format @@ -40,117 +52,53 @@ postgres://user:password@host:5432/dbname?sslmode=disable postgres://user:password@host:5432/dbname?sslmode=require&sslrootcert=/path/to/ca.pem ``` -### Advanced pgdriver options - -```go -import "github.com/xraph/grove/drivers/pgdriver" - -db := grove.Open(pgdriver.New( - pgdriver.WithDSN(os.Getenv("DATABASE_URL")), - pgdriver.WithMaxOpenConns(25), - pgdriver.WithMaxIdleConns(5), - pgdriver.WithConnMaxLifetime(5 * time.Minute), -)) -``` - ### Accessing the underlying DB ```go -pgStore := postgres.New(db) -rawDB := pgStore.DB() // *grove.DB — for advanced use cases +rawDB := pgStore.DB() // the *grove.DB you passed in ``` ## Wiring into the engine ```go -package main - -import ( - "context" - "log" - "net/http" - "os" - "time" - - "github.com/xraph/authsome" - "github.com/xraph/authsome/plugins/password" - "github.com/xraph/authsome/plugins/social" - "github.com/xraph/authsome/store/postgres" - "github.com/xraph/grove" - "github.com/xraph/grove/drivers/pgdriver" +eng, err := authsome.NewEngine( + authsome.WithStore(pgStore), + authsome.WithDriverName("pg"), + authsome.WithDB(db), + authsome.WithChronicle(chronicleBridge), + authsome.WithWarden(wardenEngine), + authsome.WithPlugin(password.New()), ) +if err != nil { + log.Fatal(err) +} -func main() { - ctx := context.Background() - - db := grove.Open(pgdriver.New(os.Getenv("DATABASE_URL"))) - pgStore := postgres.New(db) - - eng, err := authsome.New( - authsome.WithStore(pgStore), - authsome.WithPlugin(password.New()), - authsome.WithPlugin(social.New(social.WithGitHub( - os.Getenv("GITHUB_CLIENT_ID"), - os.Getenv("GITHUB_CLIENT_SECRET"), - "https://myapp.example.com/v1/auth/social/github/callback", - ))), - authsome.WithConfig(authsome.Config{ - AppID: "myapp", - BasePath: "/v1/auth", - Session: authsome.SessionConfig{ - TokenTTL: 1 * time.Hour, - RefreshTokenTTL: 30 * 24 * time.Hour, - }, - }), - ) - if err != nil { - log.Fatal(err) - } - defer eng.Stop(ctx) - - // Start runs migrations automatically. - if err := eng.Start(ctx); err != nil { - log.Fatal(err) - } - - mux := http.NewServeMux() - eng.RegisterRoutes(mux) - log.Fatal(http.ListenAndServe(":8080", mux)) +// Start runs the core and plugin migrations. +if err := eng.Start(ctx); err != nil { + log.Fatal(err) } +defer eng.Stop(ctx) ``` -## Migrations - -Migrations run automatically when `eng.Start(ctx)` is called. You never need to manage schema evolution manually. - -The PostgreSQL store uses Grove's programmatic migration system (`migrate.Group`). Each migration is a Go function that calls Grove ORM methods (`CreateTable`, `AddColumn`, etc.) rather than raw SQL strings. This makes migrations database-portable and testable. +`WithDriverName("pg")` tells plugins which migration groups to contribute, and `WithDB(db)` gives plugins with their own stores (mfa, social, sso, oauth2provider, scim and others) the database; without it they keep their data in memory. Without the driver name, the core tables are created but the plugin-only ones (SSO identities, consents, the OAuth2 provider's tables and so on) aren't. The [Getting Started](/docs/getting-started) page shows how to build the Chronicle and Warden values and serve the API; the [Full Example](/docs/guides/full-example) runs the whole thing on PostgreSQL. -The core migration group creates all tables for the built-in subsystems. Plugin migration groups are merged and executed in the same orchestrator run, so all tables are created atomically. - -### Migration tracking +## Migrations -Grove creates a `grove_migrations` table to track which migrations have been applied. On subsequent `Start` calls, already-applied migrations are skipped. The orchestrator is fully idempotent — calling `Start` on every application boot is safe and expected. +Unless you pass `WithDisableMigrate()`, `eng.Start(ctx)` applies the pending migrations: the store's core group plus every group the registered plugins return for the driver name. Grove records applied migrations in `grove_migrations`, so running `Start` on every boot is safe. -### Running migrations manually +### Running migrations separately -If you need to run migrations outside the engine lifecycle (e.g., in a one-off migration script): +To migrate in a deploy step rather than at server start, build an engine with the same store, driver name and plugins, and call: ```go -import "github.com/xraph/grove/migrate" - -if err := pgStore.Migrate(ctx); err != nil { - log.Fatal("migration failed:", err) +if err := eng.EnsureMigrated(ctx); err != nil { + log.Fatal(err) } ``` -To also run plugin migrations: +Then start the servers with `WithDisableMigrate()`. `EnsureMigrated` does nothing on an engine built with `WithDisableMigrate()`. -```go -pwPlugin := password.New() -if err := pgStore.Migrate(ctx, pwPlugin.MigrationGroup()); err != nil { - log.Fatal("migration failed:", err) -} -``` +You can also call the store directly with extra groups: `pgStore.Migrate(ctx, groups...)`. A plugin that ships migrations implements `plugin.MigrationProvider`, whose `MigrationGroups("pg")` returns them. ## Table naming @@ -158,88 +106,37 @@ All Authsome tables use the `authsome_` prefix to avoid collisions with applicat | Table | Purpose | |-------|---------| -| `authsome_users` | User records | -| `authsome_sessions` | Active sessions | -| `authsome_verifications` | Email verification tokens | -| `authsome_password_resets` | Password reset tokens | -| `authsome_apps` | Application registrations | -| `authsome_organizations` | Organization records | -| `authsome_members` | Organization membership | -| `authsome_invitations` | Pending invitations | -| `authsome_teams` | Teams within organizations | -| `authsome_devices` | Device fingerprint records | -| `authsome_webhooks` | Webhook endpoints | -| `authsome_notifications` | Notification queue | -| `authsome_api_keys` | API key records | -| `authsome_environments` | Environment isolation records | -| `authsome_form_configs` | Dynamic form field configurations | -| `authsome_branding_configs` | Per-org branding settings | -| `authsome_app_session_configs` | Per-app session token overrides | - -Plugin tables follow their own naming conventions. The `password` plugin creates `authsome_password_hashes`. The `mfa` plugin creates `authsome_mfa_enrollments`. The `passkey` plugin creates `authsome_passkeys`. Refer to each plugin's documentation for its table list. - -## Grove ORM patterns - -The PostgreSQL store uses Grove ORM's query builder throughout. Key patterns used internally: - -### Insert - -```go -_, err := s.pg.NewInsert(model).Exec(ctx) -``` - -### Select by primary key - -```go -err := s.pg.NewSelect(model).Where("id = ?", id.String()).Scan(ctx) -``` - -### Soft delete (users) - -Users are soft-deleted — a `deleted_at` timestamp is set rather than removing the row: - -```go -_, err := s.pg.NewUpdate((*UserModel)(nil)). - Set("deleted_at = ?", time.Now()). - Where("id = ?", userID.String()). - Where("deleted_at IS NULL"). - Exec(ctx) -``` - -All user lookups include `WHERE deleted_at IS NULL` to exclude deleted users. - -### Cursor-based pagination - -`ListUsers` and similar list methods use cursor-based pagination for O(1) page navigation regardless of result set size: - -```go -query := s.pg.NewSelect(&models). - Where("app_id = ?", appID.String()). - Where("deleted_at IS NULL") - -if q.Cursor != "" { - query = query.Where("id < ?", q.Cursor) -} - -query = query.OrderExpr("id DESC").Limit(limit + 1) -``` - -The response includes a `NextCursor` field set to the last-seen ID when more results are available. +| `authsome_apps` | Applications | +| `authsome_environments` | Environments per app | +| `authsome_users`, `authsome_user_emails` | Users and their addresses | +| `authsome_sessions`, `authsome_revoked_refresh_tokens` | Sessions and refresh-token replay records | +| `authsome_verifications`, `authsome_password_resets` | Email verification and reset tokens | +| `authsome_organizations`, `authsome_members`, `authsome_invitations`, `authsome_teams` | Organizations | +| `authsome_devices` | Devices | +| `authsome_webhooks`, `authsome_notifications` | Webhooks and notifications | +| `authsome_api_keys`, `authsome_service_accounts` | API keys and service accounts | +| `authsome_mfa_enrollments`, `authsome_mfa_recovery_codes` | MFA | +| `authsome_passkey_credentials` | Passkeys | +| `authsome_oauth_connections` | Linked social accounts | +| `authsome_sso_connections` | SSO connections | +| `authsome_delegations` | Delegations | +| `authsome_form_configs`, `authsome_branding_configs` | Dynamic forms and branding | +| `authsome_app_session_configs`, `authsome_app_client_configs` | Per-app session and client settings | +| `authsome_settings`, `authsome_kv` | Settings and key-value data | + +Plugins add their own tables through their migration groups, for example `authsome_sso_identities` (sso), `authsome_consents` (consent), `authsome_scim_*` (scim), `authsome_waitlist_entries` (waitlist) and the `authsome_oauth*` tables of the OAuth2 provider. ## Lifecycle methods | Method | Behaviour | |--------|-----------| -| `Migrate(ctx, extraGroups...)` | Creates all tables via Grove's migration orchestrator. Idempotent. | -| `Ping(ctx)` | Calls `db.Ping(ctx)` to verify database connectivity. | -| `Close()` | Calls `db.Close()` to release all pooled connections. | +| `Migrate(ctx, extraGroups...)` | Runs the core group and any extra groups through Grove's orchestrator. Idempotent | +| `Ping(ctx)` | Checks connectivity with `db.Ping` | +| `Close()` | Closes the `*grove.DB` | ## When to use -- **Production deployments** — ACID-compliant, connection-pooled, supports all Authsome features including RBAC with hierarchical roles. -- **Staging environments** — Mirror production behaviour exactly. -- **Multi-instance deployments** — All application instances share the same database; no sticky sessions required. -- **High-traffic applications** — pgdriver's connection pool handles hundreds of concurrent authentication requests efficiently. +Use it for production and staging, and whenever more than one instance shares the user base: every instance reads the same tables, so you don't need sticky sessions. ## Machine API keys and schema compatibility diff --git a/docs/content/docs/stores/sqlite.mdx b/docs/content/docs/stores/sqlite.mdx index 58179b11..d73275b7 100644 --- a/docs/content/docs/stores/sqlite.mdx +++ b/docs/content/docs/stores/sqlite.mdx @@ -3,7 +3,7 @@ title: SQLite description: Lightweight embedded SQLite backend using Grove ORM with sqlitedriver. --- -The SQLite store (`store/sqlite`) provides an embedded, file-based backend using Grove ORM with `sqlitedriver`. It implements the full `store.Store` composite interface and requires no external database server — the entire Authsome dataset lives in a single `.db` file on disk. +The SQLite store (`store/sqlite`) provides an embedded, file-based backend using Grove ORM with `sqlitedriver`. It implements `store.Store` and needs no database server: everything lives in one file on disk. ## Installation @@ -13,88 +13,61 @@ go get github.com/xraph/grove go get github.com/xraph/grove/drivers/sqlitedriver ``` -SQLite on macOS and Linux is available without CGO using the pure-Go `modernc.org/sqlite` driver that `sqlitedriver` wraps. No system library installation is needed. +`sqlitedriver` wraps the pure-Go `modernc.org/sqlite`, so you don't need CGO or a system library. ## Creating a store ```go import ( + "log" + "github.com/xraph/grove" "github.com/xraph/grove/drivers/sqlitedriver" + "github.com/xraph/authsome/store/sqlite" ) -// Open a Grove DB backed by SQLite. -db := grove.Open(sqlitedriver.New("authsome.db")) +lite := sqlitedriver.New() +if err := lite.Open(ctx, "file:authsome.db"); err != nil { + log.Fatal(err) +} +db, err := grove.Open(lite) +if err != nil { + log.Fatal(err) +} -// Create the Authsome store. sqStore := sqlite.New(db) ``` -`sqlitedriver.New` accepts a file path. Pass `":memory:"` to use a transient in-memory SQLite database (distinct from the `store/memory` package — this still uses SQLite semantics and supports full migrations). - -```go -// In-memory SQLite (useful for fast integration tests with real SQL behaviour) -db := grove.Open(sqlitedriver.New(":memory:")) -sqStore := sqlite.New(db) -``` +The DSN is passed to `modernc.org/sqlite`. A `:memory:` DSN gives you a throwaway database that still runs the real migrations, unlike `store/memory`. ## Wiring into the engine ```go -package main - -import ( - "context" - "log" - "net/http" - "time" - - "github.com/xraph/authsome" - "github.com/xraph/authsome/plugins/password" - "github.com/xraph/authsome/store/sqlite" - "github.com/xraph/grove" - "github.com/xraph/grove/drivers/sqlitedriver" +eng, err := authsome.NewEngine( + authsome.WithStore(sqStore), + authsome.WithDriverName("sqlite"), + authsome.WithDB(db), + authsome.WithChronicle(chronicleBridge), + authsome.WithWarden(wardenEngine), + authsome.WithPlugin(password.New()), ) - -func main() { - ctx := context.Background() - - db := grove.Open(sqlitedriver.New("authsome.db")) - sqStore := sqlite.New(db) - - eng, err := authsome.New( - authsome.WithStore(sqStore), - authsome.WithPlugin(password.New()), - authsome.WithConfig(authsome.Config{ - AppID: "myapp", - BasePath: "/v1/auth", - Session: authsome.SessionConfig{ - TokenTTL: 1 * time.Hour, - RefreshTokenTTL: 7 * 24 * time.Hour, - }, - }), - ) - if err != nil { - log.Fatal(err) - } - defer eng.Stop(ctx) - - if err := eng.Start(ctx); err != nil { - log.Fatal(err) - } - - mux := http.NewServeMux() - eng.RegisterRoutes(mux) - log.Fatal(http.ListenAndServe(":8080", mux)) +if err != nil { + log.Fatal(err) } +if err := eng.Start(ctx); err != nil { + log.Fatal(err) +} +defer eng.Stop(ctx) ``` +`WithDriverName("sqlite")` lets plugins add their SQLite migrations, and `WithDB(db)` lets plugins with their own stores use the database instead of memory. Serve the API the way [Getting Started](/docs/getting-started) does. + ## Migrations -Like all Authsome backends, migrations run automatically on `eng.Start(ctx)`. The SQLite store uses the same Grove ORM migration orchestrator as the PostgreSQL backend. Migration history is tracked in a `grove_migrations` table within the SQLite file. +Migrations run on `eng.Start(ctx)` unless you pass `WithDisableMigrate()`, through the same Grove orchestrator as PostgreSQL. Applied migrations are recorded in `grove_migrations` inside the file. -Running migrations manually: +To run the core migrations yourself: ```go if err := sqStore.Migrate(ctx); err != nil { @@ -104,41 +77,31 @@ if err := sqStore.Migrate(ctx); err != nil { ## Table naming -SQLite uses the same `authsome_` table prefix as the PostgreSQL backend. All table names, column names, and constraint semantics are identical. The Grove ORM migration system generates SQLite-compatible DDL from the same model definitions. +SQLite uses the same `authsome_` table names as PostgreSQL. The DDL is SQLite's own, kept in `store/sqlite`'s migration group. ## Lifecycle methods | Method | Behaviour | |--------|-----------| -| `Migrate(ctx, extraGroups...)` | Creates all tables using the Grove migration orchestrator. | -| `Ping(ctx)` | Calls `db.Ping(ctx)` to verify the SQLite file is accessible. | -| `Close()` | Calls `db.Close()` to flush WAL pages and close the file. | +| `Migrate(ctx, extraGroups...)` | Runs the core group and any extra groups through the Grove orchestrator | +| `Ping(ctx)` | Checks the database with `db.Ping` | +| `Close()` | Closes the `*grove.DB` | -Always call `Close()` before your process exits. SQLite uses a write-ahead log (WAL) and data written since the last checkpoint may not be persisted unless `Close` is called cleanly. +Call `Close()` before the process exits. ## Limitations compared to PostgreSQL | Feature | SQLite | PostgreSQL | |---------|--------|------------| -| Concurrent writers | Single writer at a time (WAL mode allows one writer + multiple readers) | Full concurrent write support | -| Network access | File only — cannot be shared across processes on different hosts | Full network access | -| Full-text search | Limited | Full `pg_trgm`, `tsvector` support | -| JSON operators | Basic JSON support | Full JSONB with GIN indexes | +| Concurrent writers | One writer at a time | Many | +| Network access | A local file, so one host | Over the network | | Multi-instance deployments | Not supported | Supported | -| Replication | Not supported | Streaming replication | -| Max practical dataset | ~1 GB for write-heavy workloads | Petabyte-scale | +| Replication | None built in | Streaming replication | ## When to use -- **Local development** — No server process to manage. Run your application with a single file. -- **Desktop or Electron apps** — Embed Authsome directly in a desktop application with a local SQLite database. -- **CLI tools** — Authenticate CLI users without requiring an external service. -- **Single-server deployments** — Low-traffic applications where a single SQLite file is sufficient. -- **Integration tests** — SQLite `:memory:` provides real SQL behaviour with Grove ORM migrations, faster than spinning up PostgreSQL but more faithful than the pure-memory store. -- **Edge functions** — Environments where running a PostgreSQL server is impractical. +Use SQLite for local development, desktop apps and CLI tools, single-server deployments with light traffic, and integration tests where you want real SQL without running PostgreSQL. ## When not to use -- **Multi-instance deployments** — SQLite cannot be shared across multiple server processes. If you run more than one instance of your application, use PostgreSQL. -- **High write throughput** — SQLite serialises writes. Under heavy concurrent write load, use PostgreSQL. -- **Production with high availability requirements** — SQLite has no built-in replication. Use PostgreSQL with streaming replication instead. +Don't use it when you run more than one instance (the file can't be shared across hosts), when writes are heavy (SQLite serialises them), or when you need replication. diff --git a/docs/content/docs/ui/flutter.mdx b/docs/content/docs/ui/flutter.mdx index 7b98004a..57688965 100644 --- a/docs/content/docs/ui/flutter.mdx +++ b/docs/content/docs/ui/flutter.mdx @@ -417,7 +417,7 @@ SignInForm( ## Auto-configuration -When `AuthProvider` fetches the client config from your backend (`/v1/auth/client-config`), the UI widgets automatically adapt: +When `AuthProvider` fetches the client config from your backend (`/v1/client-config`), the UI widgets automatically adapt: - **Social providers**: `SignInForm` and `SignUpForm` show buttons for providers enabled in your dashboard - **MFA methods**: `MfaChallengeForm` shows tabs only for enabled methods (TOTP, SMS, recovery) diff --git a/docs/content/docs/ui/nextjs.mdx b/docs/content/docs/ui/nextjs.mdx index 82404eac..1e424855 100644 --- a/docs/content/docs/ui/nextjs.mdx +++ b/docs/content/docs/ui/nextjs.mdx @@ -44,7 +44,7 @@ export default async function DashboardPage() { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `baseURL` | `string` | -- | Authsome API URL | +| `baseURL` | `string` | none | Authsome API URL | | `cookieName` | `string` | `"authsome_session_token"` | Cookie name for the session token | ### getClientConfig @@ -80,8 +80,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo | Option | Type | Default | Description | |--------|------|---------|-------------| -| `baseURL` | `string` | -- | Authsome API URL | -| `publishableKey` | `string` | -- | Publishable key to identify the app | +| `baseURL` | `string` | none | Authsome API URL | +| `publishableKey` | `string` | none | Publishable key to identify the app | #### ClientConfig shape @@ -162,7 +162,7 @@ export const config = { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `baseURL` | `string` | -- | Authsome API URL | +| `baseURL` | `string` | none | Authsome API URL | | `signInPage` | `string` | `"/sign-in"` | Redirect target for unauthenticated users | | `publicPaths` | `string[]` | `["/", signInPage]` | Paths that do not require authentication. Supports glob suffixes with `*` | | `cookieName` | `string` | `"authsome_session_token"` | Cookie name to read | @@ -196,13 +196,13 @@ const handler = createProxyHandler({ export { handler as GET, handler as POST, handler as PUT, handler as DELETE, handler as PATCH }; ``` -This single file replaces any manual proxy implementation. All requests to `/api/authsome/*` will be forwarded to the corresponding path on your Authsome backend (e.g., `/api/authsome/v1/auth/signin` proxies to `/v1/auth/signin`). +This single file replaces any manual proxy implementation. All requests to `/api/authsome/*` will be forwarded to the corresponding path on your Authsome backend (e.g., `/api/authsome/v1/signin` proxies to `/v1/signin`). #### Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| -| `baseURL` | `string` | -- | Base URL of the Authsome backend API | +| `baseURL` | `string` | none | Base URL of the Authsome backend API | > **Important:** Add `/api/authsome/*` to your middleware's `publicPaths` array so the proxy route is accessible without authentication. @@ -390,7 +390,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { return { redirect: { destination: "/sign-in", permanent: false } }; } - const res = await fetch(`${process.env.AUTHSOME_API_URL}/v1/auth/me`, { + const res = await fetch(`${process.env.AUTHSOME_API_URL}/v1/me`, { headers: { Authorization: `Bearer ${token}` }, }); diff --git a/docs/content/docs/ui/overview.mdx b/docs/content/docs/ui/overview.mdx index 1b380941..738b7763 100644 --- a/docs/content/docs/ui/overview.mdx +++ b/docs/content/docs/ui/overview.mdx @@ -1,6 +1,6 @@ --- title: Authsome UI Overview -description: Pre-built authentication UI components for React, Next.js, and Vue — headless hooks plus styled Radix + Tailwind components. +description: "Pre-built authentication UI components for React, Next.js, and Vue: headless hooks plus styled Radix + Tailwind components." --- Authsome UI is a family of five JavaScript/TypeScript packages that provide authentication UI for any React, Next.js, or Vue application backed by an Authsome API. The packages are layered: each higher-level package builds on the one below it, and you choose the level of abstraction that fits your project. @@ -23,11 +23,11 @@ Authsome UI is a family of five JavaScript/TypeScript packages that provide auth | Package | Version | What it provides | |---------|---------|-----------------| -| `@authsome/ui-core` | — | `AuthManager` class, `AuthClient` HTTP client, TypeScript type definitions (`User`, `Session`, `Organization`, `AuthState`, etc.) | -| `@authsome/ui-react` | — | `AuthProvider`, `useAuth`, `useUser`, `useOrganizations`, `useSessionToken`, `useClientConfig`, headless `SignInForm`, `SignUpForm`, `MFAChallengeForm`, `AuthGuard` | -| `@authsome/ui-components` | — | 40+ styled components built with Radix UI primitives and Tailwind CSS | -| `@authsome/ui-nextjs` | — | `getServerSession()` for RSC, `getClientConfig()` for auto-discovery, `createCookieStorage()`, `createAuthMiddleware()` for Edge, `createProxyHandler()` for API proxy, pre-built Next.js auth pages | -| `@authsome/ui-vue` | — | `createAuthPlugin()`, `useAuth`, `useUser`, `useOrganizations`, `useSessionToken` composables | +| `@authsome/ui-core` | none | `AuthManager` class, `AuthClient` HTTP client, TypeScript type definitions (`User`, `Session`, `Organization`, `AuthState`, etc.) | +| `@authsome/ui-react` | none | `AuthProvider`, `useAuth`, `useUser`, `useOrganizations`, `useSessionToken`, `useClientConfig`, headless `SignInForm`, `SignUpForm`, `MFAChallengeForm`, `AuthGuard` | +| `@authsome/ui-components` | none | 40+ styled components built with Radix UI primitives and Tailwind CSS | +| `@authsome/ui-nextjs` | none | `getServerSession()` for RSC, `getClientConfig()` for auto-discovery, `createCookieStorage()`, `createAuthMiddleware()` for Edge, `createProxyHandler()` for API proxy, pre-built Next.js auth pages | +| `@authsome/ui-vue` | none | `createAuthPlugin()`, `useAuth`, `useUser`, `useOrganizations`, `useSessionToken` composables | ## Installation @@ -61,17 +61,17 @@ npm install @authsome/ui-vue @authsome/ui-core | Feature | `ui-core` | `ui-react` | `ui-components` | `ui-nextjs` | `ui-vue` | |---------|-----------|------------|-----------------|-------------|----------| -| TypeScript types | Yes | Re-exports | — | Re-exports | — | -| HTTP client (`AuthClient`) | Yes | Via context | — | Via context | Via composables | +| TypeScript types | Yes | Re-exports | none | Re-exports | none | +| HTTP client (`AuthClient`) | Yes | Via context | none | Via context | Via composables | | State management | `AuthManager` | `AuthProvider` | Uses React | Uses React | `createAuthPlugin` | -| Hooks / composables | — | Yes | — | Re-exports | Yes | -| Headless form components | — | Yes | — | Re-exports | — | -| Styled form components | — | — | Yes | — | — | -| Server session (`RSC`) | — | — | — | Yes | — | -| Client config (auto-discovery) | — | `useClientConfig` | — | `getClientConfig` | — | -| Edge middleware | — | — | — | Yes | — | -| API proxy handler | — | — | — | Yes | — | -| Cookie token storage | — | — | — | Yes | — | +| Hooks / composables | none | Yes | none | Re-exports | Yes | +| Headless form components | none | Yes | none | Re-exports | none | +| Styled form components | none | none | Yes | none | none | +| Server session (`RSC`) | none | none | none | Yes | none | +| Client config (auto-discovery) | none | `useClientConfig` | none | `getClientConfig` | none | +| Edge middleware | none | none | none | Yes | none | +| API proxy handler | none | none | none | Yes | none | +| Cookie token storage | none | none | none | Yes | none | ## Core concepts @@ -91,7 +91,7 @@ import { AuthManager } from "@authsome/ui-core"; const manager = new AuthManager({ baseURL: "https://api.example.com", // Defaults to in-memory. createLocalStorage() persists across reloads but - // makes the refresh token readable by any script on the page — prefer + // makes the refresh token readable by any script on the page: prefer // backend-set httpOnly cookies where you can. storage: createLocalStorage(), }); @@ -105,7 +105,7 @@ const state = manager.getState(); ### AuthClient -`AuthClient` in `@authsome/ui-core` is the typed HTTP client that talks to the Authsome API. It wraps all endpoints under `/v1/auth`: +`AuthClient` in `@authsome/ui-core` is the typed HTTP client that talks to the Authsome API. It wraps all endpoints under `/v1`: ```typescript import { AuthClient } from "@authsome/ui-core"; diff --git a/docs/content/docs/users-sessions/device-tracking.mdx b/docs/content/docs/users-sessions/device-tracking.mdx index e60f790e..b477f3d0 100644 --- a/docs/content/docs/users-sessions/device-tracking.mdx +++ b/docs/content/docs/users-sessions/device-tracking.mdx @@ -9,31 +9,32 @@ The device tracking plugin records information about the devices users sign in f ```go type Device struct { - 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"` - 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"` } ``` Key fields: -- **`Type`** — device category: `"desktop"`, `"mobile"`, `"tablet"`, `"unknown"` -- **`Browser`** — parsed browser name from the User-Agent header (e.g., `"Chrome"`, `"Safari"`, `"Firefox"`) -- **`OS`** — parsed operating system from the User-Agent (e.g., `"macOS"`, `"Windows 11"`, `"iOS 17"`) -- **`Fingerprint`** — a unique identifier for the device, typically derived from client-side fingerprinting -- **`Trusted`** — when `true`, MFA challenges may be skipped for this device -- **`LastSeenAt`** — updated on every authenticated request from this device +- `Type`: the device category, one of `"desktop"`, `"mobile"`, `"tablet"`, `"unknown"` +- `Browser`: parsed browser name from the User-Agent header (e.g., `"Chrome"`, `"Safari"`, `"Firefox"`) +- `OS`: parsed operating system from the User-Agent (e.g., `"macOS"`, `"Windows 11"`, `"iOS 17"`) +- `Fingerprint`: a unique identifier for the device, typically derived from client-side fingerprinting +- `Trusted`: when `true`, MFA challenges may be skipped for this device +- `LastSeenAt`: updated on every authenticated request from this device - Device IDs have the prefix `adev_`. ## How device tracking works @@ -55,7 +56,7 @@ To enable device identification across sessions, the client should send a finger X-Device-Fingerprint: abc123def456 ``` -The fingerprint can be generated using any client-side fingerprinting library. The value should be stable across sessions but unique per device. Authsome does not prescribe a specific fingerprinting algorithm — popular choices include: +The fingerprint can be generated using any client-side fingerprinting library. The value should be stable across sessions but unique per device. Authsome does not prescribe a specific fingerprinting algorithm: popular choices include: - [FingerprintJS](https://fingerprint.com) for browsers - Device identifier (IDFV/Android ID) for mobile apps @@ -67,12 +68,12 @@ If no fingerprint header is sent, the engine falls back to IP address + User-Age Marking a device as trusted has two effects: -1. **MFA bypass** — when a device is trusted and the MFA configuration allows it, the user may skip the MFA challenge on subsequent sign-ins from that device -2. **Security signals** — sign-ins from new (untrusted) devices can trigger notifications or additional verification +1. MFA bypass: when a device is trusted and the MFA configuration allows it, the user may skip the MFA challenge on subsequent sign-ins from that device +2. Security signals: sign-ins from new (untrusted) devices can trigger notifications or additional verification ### Trust a device -`PATCH /v1/auth/devices/:deviceId/trust` +`PATCH /v1/devices/:deviceId/trust` ``` Authorization: Bearer @@ -130,7 +131,7 @@ This prevents session token theft from being useful across devices. Even if an a Users can view all tracked devices: -`GET /v1/auth/devices` +`GET /v1/devices` ``` Authorization: Bearer @@ -171,13 +172,13 @@ Authorization: Bearer ## Get device details -`GET /v1/auth/devices/:deviceId` +`GET /v1/devices/:deviceId` Returns the full device record for a specific device. ## Remove a device -`DELETE /v1/auth/devices/:deviceId` +`DELETE /v1/devices/:deviceId` ``` Authorization: Bearer @@ -213,7 +214,7 @@ type Store interface { ## API routes -All device routes are registered under `BasePath` (default `/v1/auth`): +All device routes are registered under `BasePath` (default `/v1`): | Method | Path | Description | |--------|------|-------------| diff --git a/docs/content/docs/users-sessions/impersonation.mdx b/docs/content/docs/users-sessions/impersonation.mdx index 6b318d5a..76ba1f71 100644 --- a/docs/content/docs/users-sessions/impersonation.mdx +++ b/docs/content/docs/users-sessions/impersonation.mdx @@ -41,14 +41,7 @@ if err != nil { ### Via HTTP API -`POST /v1/auth/admin/impersonate` - -```json -{ - "target_user_id": "ausr_01jb...", - "app_id": "myapp" -} -``` +`POST /v1/admin/impersonate/:userId`, with the target's user ID in the path and no body. ``` Authorization: Bearer @@ -76,9 +69,10 @@ The admin can now use the returned `session_token` to make requests as the targe Impersonation sessions are regular sessions and can be ended in the usual ways: -- **Sign out**: `POST /v1/auth/signout` with the impersonation session token +- **Stop**: `POST /v1/admin/impersonate/stop` +- **Sign out**: `POST /v1/signout` with the impersonation session token - **Token expiry**: The session expires based on the configured TTL -- **Session revocation**: Delete the session via `DELETE /v1/auth/sessions/:sessionId` +- **Session revocation**: Delete the session via `DELETE /v1/sessions/:sessionId` Impersonation sessions typically have a shorter TTL than regular sessions to limit the window of elevated access. @@ -166,4 +160,5 @@ for _, s := range sessions { | Method | Path | Description | |--------|------|-------------| -| `POST` | `/admin/impersonate` | Start an impersonation session (admin only) | +| `POST` | `/v1/admin/impersonate/:userId` | Start an impersonation session (admin only) | +| `POST` | `/v1/admin/impersonate/stop` | End it | diff --git a/docs/content/docs/users-sessions/session-lifecycle.mdx b/docs/content/docs/users-sessions/session-lifecycle.mdx index ceadc9e2..609c06d3 100644 --- a/docs/content/docs/users-sessions/session-lifecycle.mdx +++ b/docs/content/docs/users-sessions/session-lifecycle.mdx @@ -43,8 +43,8 @@ Key fields: Sessions are created automatically on successful authentication. The engine generates two cryptographically random tokens: -1. **Session token** (access token) — short-lived, used for API authorization -2. **Refresh token** — long-lived, used to obtain new session tokens +1. **Session token** (access token): short-lived, used for API authorization +2. Refresh token: long-lived, used to obtain new session tokens ```go import "github.com/xraph/authsome/account" @@ -78,7 +78,8 @@ When a session is created during sign-in or sign-up, both tokens are returned: "user": { "id": "ausr_01j9...", "email": "alice@example.com", - "name": "Alice Liddell" + "first_name": "Alice", + "last_name": "Liddell" }, "session_token": "a3f8c9d4e5f2g7h1...", "refresh_token": "d72b1ef8a4c6e9b3...", @@ -92,7 +93,7 @@ The client should store both tokens securely. The session token is sent as `Auth When the session token expires, the client uses the refresh token to obtain new tokens without re-authenticating. -`POST /v1/auth/refresh` +`POST /v1/refresh` ```json { @@ -134,7 +135,7 @@ If an attacker attempts to use a previously rotated refresh token, the engine de ### Revoke current session (sign out) -`POST /v1/auth/signout` +`POST /v1/signout` ``` Authorization: Bearer @@ -146,7 +147,7 @@ Authorization: Bearer ### Revoke a specific session -`DELETE /v1/auth/sessions/:sessionId` +`DELETE /v1/sessions/:sessionId` ``` Authorization: Bearer @@ -184,7 +185,7 @@ sessions, err := engine.ListSessions(ctx, userID) Users can view all their active sessions: -`GET /v1/auth/sessions` +`GET /v1/sessions` ``` Authorization: Bearer diff --git a/docs/content/docs/users-sessions/token-formats.mdx b/docs/content/docs/users-sessions/token-formats.mdx index 70c073e9..9262245d 100644 --- a/docs/content/docs/users-sessions/token-formats.mdx +++ b/docs/content/docs/users-sessions/token-formats.mdx @@ -14,22 +14,18 @@ Authorization: Bearer a3f8c9d4e5f2g7h1j2k3m4n5p6q7r8s9 ``` **Advantages:** -- Immediate revocation — deleting the session record instantly invalidates the token +- Immediate revocation: deleting the session record instantly invalidates the token - No token size overhead - No cryptographic key management required - Tokens cannot be decoded by clients (no information leakage) **Trade-offs:** - Every request requires a session store lookup -- Not self-contained — cannot be verified without contacting the auth server +- Not self-contained: cannot be verified without contacting the auth server ### Configuration -```go -Session: authsome.SessionConfig{ - TokenFormat: "opaque", // default -} -``` +Opaque is the default; you don't configure anything to get it. ## JWT tokens @@ -40,7 +36,7 @@ Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI0LT ``` **Advantages:** -- Stateless verification — no session store lookup needed +- Stateless verification: no session store lookup needed - Self-contained user claims reduce API calls - Compatible with OIDC-compliant resource servers - Can be verified by any service with access to the public key @@ -53,18 +49,34 @@ Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI0LT ### Configuration ```go -Session: authsome.SessionConfig{ - TokenFormat: "jwt", - JWT: authsome.JWTConfig{ - SigningAlgorithm: "RS256", // RS256, RS384, RS512, ES256, ES384, ES512 - Issuer: "https://auth.myapp.com", - Audience: []string{"https://api.myapp.com"}, - KeyID: "key-2024-01", - PrivateKeyPath: "/path/to/private.pem", - }, +import ( + "github.com/golang-jwt/jwt/v5" + + "github.com/xraph/authsome" + "github.com/xraph/authsome/tokenformat" +) + +jwtFmt, err := tokenformat.NewJWT(tokenformat.JWTConfig{ + SigningMethod: jwt.SigningMethodRS256, // or ES256, HS256 and the rest + SigningKey: rsaPrivateKey, // *rsa.PrivateKey, *ecdsa.PrivateKey or []byte for HMAC + VerifyKey: &rsaPrivateKey.PublicKey, + KeyID: "key-2024-01", + Issuer: "https://auth.myapp.com", + Audience: "https://api.myapp.com", +}) +if err != nil { + log.Fatal(err) } + +eng, err := authsome.NewEngine( + // plus WithStore, WithChronicle and WithWarden (see Getting Started) + authsome.WithJWTFormat(appID.String(), jwtFmt), // JWTs for one app + // authsome.WithDefaultTokenFormat(jwtFmt), // or for every app +) ``` +`tokenformat.NewJWT` takes the key itself, not a file path; load the PEM yourself. An app can also switch formats at runtime through its session config (`TokenFormat: "jwt"` on `appsessionconfig.Config`). + ## JWT claims structure When using JWT format, session tokens contain the following claims: @@ -78,18 +90,18 @@ When using JWT format, session tokens contain the following claims: "aud": ["https://api.myapp.com"], "exp": 1730455200, "iat": 1730451600, - "jti": "ases_01j9..." + "nbf": 1730451600 } ``` | Claim | Description | |-------|-------------| -| `iss` | Issuer — your Authsome instance URL | -| `sub` | Subject — the user ID | -| `aud` | Audience — intended token recipients | +| `iss` | Issuer: your Authsome instance URL | +| `sub` | Subject: the user ID | +| `aud` | Audience: intended token recipients | | `exp` | Expiration time (Unix timestamp) | | `iat` | Issued at time (Unix timestamp) | -| `jti` | JWT ID — the session ID (unique per token) | +| `nbf` | Not before (Unix timestamp) | ### Authsome custom claims @@ -97,13 +109,9 @@ When using JWT format, session tokens contain the following claims: { "app_id": "aapp_01j9...", "env_id": "aenv_01j9...", - "email": "alice@example.com", - "email_verified": true, - "name": "Alice Liddell", - "username": "alice", "org_id": "aorg_01j9...", - "roles": ["admin", "member"], - "session_id": "ases_01j9..." + "sid": "ases_01j9...", + "scopes": ["openid", "profile"] } ``` @@ -111,13 +119,15 @@ When using JWT format, session tokens contain the following claims: |-------|-------------| | `app_id` | Application the session belongs to | | `env_id` | Environment the session belongs to | -| `email` | User's email address | -| `email_verified` | Whether the email has been verified | -| `name` | User's display name | -| `username` | User's username | -| `org_id` | Organization scope (if session is org-scoped) | -| `roles` | User's RBAC roles | -| `session_id` | The Authsome session ID | +| `org_id` | Active organization, when the session has one | +| `sid` | The Authsome session ID | +| `scopes` | Granted scopes, when the token has them | +| `act` | The actor chain on a delegated token | +| `pk`, `pid` | Principal kind and ID for non-user callers | +| `client_id` | The OAuth2 client, for tokens the OAuth2 provider issued | +| `cnf` | `{"jkt": ...}` for DPoP-bound tokens | + +The access token doesn't carry email, name or roles. Read those from `GET /v1/me` or the OIDC userinfo endpoint. ## OIDC-compatible claims diff --git a/docs/content/docs/users-sessions/user-management.mdx b/docs/content/docs/users-sessions/user-management.mdx index 61dd2c6c..394b9a3a 100644 --- a/docs/content/docs/users-sessions/user-management.mdx +++ b/docs/content/docs/users-sessions/user-management.mdx @@ -45,13 +45,14 @@ Users are typically created through the sign-up flow, but can also be created di ### Via sign-up -`POST /v1/auth/signup` +`POST /v1/signup` ```json { "email": "alice@example.com", "password": "Secure!Pass99", - "name": "Alice Liddell", + "first_name": "Alice", + "last_name": "Liddell", "username": "alice", "app_id": "myapp", "metadata": { @@ -90,7 +91,7 @@ err := engine.CreateUser(ctx, u) Authenticated users can retrieve their own profile: -`GET /v1/auth/me` +`GET /v1/me` ``` Authorization: Bearer @@ -104,7 +105,8 @@ Authorization: Bearer "app_id": "aapp_01j9...", "email": "alice@example.com", "email_verified": true, - "name": "Alice Liddell", + "first_name": "Alice", + "last_name": "Liddell", "username": "alice", "phone": "+15551234567", "phone_verified": true, @@ -125,11 +127,12 @@ u, err := engine.GetMe(ctx, userID) Authenticated users can update their own profile fields: -`PATCH /v1/auth/me` +`PATCH /v1/me` ```json { - "name": "Alice Wonderland", + "first_name": "Alice", + "last_name": "Wonderland", "username": "alicew", "image": "https://cdn.example.com/alice.jpg" } @@ -150,7 +153,7 @@ err = engine.UpdateMe(ctx, u) Users can permanently delete their own account (GDPR right to erasure): -`DELETE /v1/auth/me` +`DELETE /v1/me` ``` Authorization: Bearer @@ -176,7 +179,7 @@ err := engine.DeleteAccount(ctx, userID) For GDPR data portability, users can export all their data: -`GET /v1/auth/me/export` +`GET /v1/me/export` ``` Authorization: Bearer @@ -251,7 +254,7 @@ The engine checks ban status during every sign-in attempt: // Simplified engine logic: if u.Banned { if u.BanExpires != nil && time.Now().After(*u.BanExpires) { - // Ban expired — auto-unban + // Ban expired: auto-unban u.Banned = false store.UpdateUser(ctx, u) } else { @@ -264,58 +267,56 @@ if u.Banned { List users with filtering and pagination: -### Via Go SDK +### From Go ```go import "github.com/xraph/authsome/user" -result, err := engine.ListUsers(ctx, &user.UserQuery{ - AppID: appID, - Email: "alice@", // partial match - Search: "Liddell", // search name, email, username - Banned: boolPtr(false), // filter by ban status - Limit: 20, - Offset: 0, +result, err := eng.AdminListUsers(ctx, &user.Query{ + AppID: appID, + EnvID: envID, // optional + Email: "alice@", // optional email filter + Limit: 20, }) -// result.Users → []*user.User -// result.Total → total count (for pagination) +// result.Users → []*user.User +// result.NextCursor → pass back as Query.Cursor for the next page +// result.Total → total count ``` -### UserQuery parameters +### Query fields | Field | Type | Description | |-------|------|-------------| | `AppID` | `id.AppID` | Required. Scope to an application | | `EnvID` | `id.EnvironmentID` | Optional. Scope to an environment | -| `Email` | `string` | Filter by email (partial match) | -| `Username` | `string` | Filter by username (partial match) | -| `Phone` | `string` | Filter by phone number | -| `Search` | `string` | Full-text search across name, email, username | -| `Banned` | `*bool` | Filter by ban status | -| `EmailVerified` | `*bool` | Filter by email verification status | -| `Limit` | `int` | Max results (default 20, max 100) | -| `Offset` | `int` | Pagination offset | -| `SortBy` | `string` | Sort field: `created_at`, `updated_at`, `email`, `name` | -| `SortOrder` | `string` | `asc` or `desc` (default `desc`) | +| `Email` | `string` | Optional email filter | +| `Cursor` | `string` | Cursor from the previous page | +| `Limit` | `int` | Page size | + +There's no full-text search, ban-status filter or sort option. ## User store interface -The user store is part of the composite store and implements: +The user store is part of `store.Store`. Its core methods (abridged; it also manages secondary email addresses): ```go type Store interface { CreateUser(ctx context.Context, u *User) error GetUser(ctx context.Context, userID id.UserID) (*User, error) - GetUserByEmail(ctx context.Context, appID id.AppID, email string) (*User, error) - GetUserByPhone(ctx context.Context, appID id.AppID, phone string) (*User, error) - GetUserByUsername(ctx context.Context, appID id.AppID, username string) (*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 *UserQuery) (*UserList, error) + ListUsers(ctx context.Context, q *Query) (*List, error) + // ...CreateUserWithPrimaryEmail, AddUserEmail, GetUserByAnyEmail, GetUserEmails, + // MarkUserEmailVerified, SetPrimaryEmail, DeleteUserEmail } ``` +A nil `envID` matches across the whole app. + ## API routes | Method | Path | Description | 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/README.md b/plugins/README.md index 37b286af..32fcea57 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,6 +1,6 @@ # Plugins -Authsome ships 26 plugins. The core engine handles users, sessions, devices and RBAC; every +Authsome ships 27 plugins. The core engine handles users, sessions, devices and RBAC; every authentication method and every policy control is a plugin you register explicitly. Nothing here is on by default, so you only carry what you use. @@ -12,7 +12,7 @@ engine, err := authsome.NewEngine( ) ``` -Each plugin has its own README with config, settings, endpoints and the hooks it runs on. +Each plugin has its own README with config, settings, endpoints and the hooks it runs on, except `retention`, which is documented in its package comment (`go doc github.com/xraph/authsome/plugins/retention`). ## Pick by what you are trying to do @@ -44,6 +44,7 @@ Each plugin has its own README with config, settings, endpoints and the hooks it | Compromise signals pushed from Google or Okta | [`sharedsignals`](sharedsignals/) | | To let an AI agent act for a user, on a leash | [`agentauth`](agentauth/) | | Country and city on every session | [`geoip`](geoip/) | +| Sign-ups, sign-ins and profile updates mirrored into a CRM (HubSpot or a generic REST endpoint) | [`retention`](retention/) | ## By category @@ -54,6 +55,7 @@ Each plugin has its own README with config, settings, endpoints and the hooks it | Communication | [`email`](email/), [`notification`](notification/) | | Risk and security | [`anomaly`](anomaly/), [`geofence`](geofence/), [`geoip`](geoip/), [`impossibletravel`](impossibletravel/), [`ipreputation`](ipreputation/), [`riskengine`](riskengine/), [`vpndetect`](vpndetect/), [`sharedsignals`](sharedsignals/) | | Provisioning | [`scim`](scim/), [`deviceverify`](deviceverify/), [`oauth2provider`](oauth2provider/), [`agentauth`](agentauth/) | +| Data | [`retention`](retention/) | ## What depends on what @@ -67,6 +69,7 @@ Most plugins stand alone. These do not, and registration order matters where it - `subscription` in organisation mode needs `organization` for its tenants. - `phone` and `mfa` share the engine's SMS bridge. Configure it once. - `waitlist` contributes notification mappings that `notification` renders. +- `retention` can gate CRM delivery on a consent grant, which needs `consent` registered and `retention.require_consent` turned on. Without them every user syncs. - `agentauth` plugs its consent gate into `oauth2provider`, automatically when both are registered on the same engine. Its org policy surface needs `organization`. Pick one of `email` or `notification`, not both. They hook the same events and you will send 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