authkit

package module
v0.68.0 Latest Latest
Warning

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

Go to latest
Published: Jun 26, 2026 License: MIT Imports: 7 Imported by: 0

README

AuthKit

Lightweight auth library for Go services.

AuthKit is based on a browser-managed bearer-token model: login/OIDC/Solana flows issue an access_token and refresh_token, frontend JavaScript stores them, protected API calls use Authorization: Bearer <access_token>, and refresh uses POST /token with the refresh token. It is not a cookie-session library: it does not currently provide opaque session_id browser cookies, HttpOnly token-cookie callbacks, or CSRF/session middleware for that model.

Note: This repo ships the HTTP transport as the top-level http package (github.com/open-rails/authkit/http). First-party router adapters live in github.com/open-rails/authkit/adapters/gin and github.com/open-rails/authkit/adapters/chi.

HTTP error responses use a Stripe-style nested envelope (same shape as OpenRails), so a client hitting either service sees one error contract:

{ "error": { "type": "invalid_request_error", "code": "password_too_short",
             "message": "Password too short.", "param": "password" } }
  • code — the stable machine code; compare against authhttp.ErrorCode constants (e.g. authhttp.ErrInvalidRequest, authhttp.ErrPasswordResetRequired), never copied string literals. These values are unchanged from prior releases.
  • type — error category, derived from the HTTP status: invalid_request_error (400/404/409), authentication_error (401), authorization_error (403), rate_limit_error (429), api_error (5xx).
  • message — human-readable (English); for display/logging, not matching.
  • param (optional) — the offending request field on validation errors.
  • metadata (optional) — machine-readable context (e.g. rate-limit retry_after_seconds/limit/remaining, action-availability fields).

The envelope type lives in the core-free authbase package (authbase.ErrorEnvelope), so the verify-only middleware emits the identical shape. Breaking as of v0.52.0 (was the flat {"error":"<code>"}); migrate clients from body.error (string) to body.error.code.

Scope (minimal)

  • Asymmetric JWT issuing (RS256) + JWKS endpoint (no persistence yet).
  • Password login and email-based password reset tokens.
  • OIDC RP (OAuth2/OIDC) with PKCE (Redis/Garnet or in-memory for ephemeral state; no DB table).
  • Passkey login/registration (WebAuthn/FIDO2) using host-configured RP ID and origins.
  • Solana wallet authentication (SIWS - Sign In With Solana).
  • Storage with Postgres + Redis/Garnet for ephemeral auth state.

Packages

  • jwt: minimal key management, signer, JWKS helper.
  • oidc: client (RP) types; implementation to follow.
  • siws: Sign In With Solana - Ed25519 signature verification for Solana wallets.
  • storage: minimal interfaces for users, passwords, providers, resets, roles, revocations.
  • migrations: embedded SQL defining the profiles schema and minimal tables.
  • adapters/gin and adapters/chi: optional router adapters that register AuthKit's canonical route specs on host-owned route groups.

Migrations

  • Postgres SQL migrations live in migrations/postgres/ and are embedded via go:embed.

  • Run them with migratekit, name-tracked per app in public.migrations so a recorded migration is never re-applied:

    ms, _ := migratekit.LoadFromFS(pgmigrations.FS)
    m := migratekit.NewPostgres(sqlDB, "authkit")
    _ = m.ApplyMigrations(ctx, ms)
    

    The bundled devserver uses this exact path. FS remains available for custom runners.

  • PostgreSQL-backed storage requires PostgreSQL 18 or newer. Older PostgreSQL versions are not supported. AuthKit migrations use native uuidv7() defaults for AuthKit-owned UUID identifiers; PostgreSQL 17 can store UUIDv7 values but does not provide the required uuidv7() function.

Configurable schema (issue 69)

  • AuthKit's tables live in the Postgres schema named by core.Config.Schema (default profiles, the historical name — leaving it unset is fully backward-compatible). Set it when multiple apps embed AuthKit against the same database and must not share auth tables (e.g. one app keeps profiles, another uses openrails_auth). Names must match ^[a-z_][a-z0-9_]*$ (max 63 bytes).

  • AuthKit never touches search_path on the host's shared pool; queries stay schema-qualified and the qualifier is rewritten to the configured schema at execution time (see internal/db/schema.go).

  • Hosts with a non-default schema must run the migrations rendered for it:

    fsys, _ := pgmigrations.FSForSchema("openrails_auth") // fs.FS; "profiles"/"" returns the embedded FS unchanged
    ms, _ := migratekit.LoadFromFS(fsys)
    
  • Pool-parameter helpers default to profiles; schema-aware variants take svc.Schema(): authhttp.RequireAdminInSchema, authhttp.IsAdminInSchema, authhttp.HasRoleDBCheckInSchema, identity.NewStoreInSchema.

Database queries (sqlc)

  • All static Postgres queries are written as raw SQL in internal/db/queries/*.sql (one file per domain) and compiled to type-safe Go by sqlc into the internal/db package (committed, never hand-edited).
  • To add or change a query: edit the .sql file, run task sqlc (runs sqlc generate + sqlc vet as a pair; vet's db-prepare rule PREPAREs every query against a real Postgres — start one with docker compose up -d postgres and apply migrations/postgres/*.up.sql), then use the generated method on db.Queries. (Tasks are defined in Taskfile.yml; task --list shows them. Install: https://taskfile.dev/installation.)
  • The schema source of truth for sqlc is migrations/postgres/ — generated code is always type-checked against the real migrations. CI fails if internal/db drifts from the query files (task sqlc-check).
  • Escape hatch: queries whose SQL is assembled at runtime stay on raw pgx with a comment explaining why (currently core.AdminListUsers). ClickHouse queries are out of sqlc's scope.

Passkeys

  • Configure core.Config.Passkeys when enabling passkey routes:

    Passkeys: core.PasskeyConfig{
      RPID:           "myapp.com",
      RPDisplayName: "My App",
      Origins:       []string{"https://myapp.com"},
    }
    

    Empty passkey config derives RPID/origin from Frontend.BaseURL.

  • Mount the default API or RoutePasskeys to expose /passkeys/register/begin, /passkeys/register/finish, /passkeys/login/begin, /passkeys/login/finish, GET /passkeys, PATCH /passkeys/{id}, and DELETE /passkeys/{id}.

  • The browser should pass AuthKit's publicKey response into navigator.credentials.create() or navigator.credentials.get(), then POST the returned PublicKeyCredential JSON to the matching finish route.

  • AuthKit requires WebAuthn user verification before minting a session and records MFA assurance claims on the access token. Passkeys do not satisfy RoleDef.RequiresMFA enrollment requirements yet.

Full DB-backed tests

  • go test ./... is a fast DB-free smoke when AUTHKIT_TEST_DATABASE_URL is unset; DB-backed integration tests skip in that mode.

  • To run the full suite locally, start the compose issuer so the devserver applies migrations, then run the Taskfile test target:

    docker compose up -d --build issuer
    task test
    

Quick Start (Gin)

package main

import (
  "github.com/gin-gonic/gin"
  authkitgin "github.com/open-rails/authkit/adapters/gin"
  core "github.com/open-rails/authkit/core"
  authhttp "github.com/open-rails/authkit/http"
)

func main() {
  // Build core.Config from your app config. Fields are grouped by concern.
  cfg := core.Config{
    Token: core.TokenConfig{
      Issuer:            "https://myapp.com",
      IssuedAudiences:   []string{"myapp"},
      ExpectedAudiences: []string{"myapp"},
    },
    Frontend: core.FrontendConfig{
      BaseURL:           "https://myapp.com",
      CallbackPath:      "/login/callback",
      VerifyPath:        "/verify",
      PasswordResetPath: "/reset",
      PasswordlessPath:  "/passwordless",
    },
    // Registration: core.RegistrationConfig{
    //   Verification:                      core.RegistrationVerificationRequired, // none|optional|required
    //   NativeUserMode:                    core.RegistrationModeOpen,             // open|invite_only|admin_only|admin_bootstrap_only|...
    //   PasswordlessLogin:                 true,
    //   PasswordlessAutoRegistration:      true,
    // },
    // Keys.Source nil => auto-discovery in AuthKit (env/fs/dev fallback)
  }

  // Postgres is REQUIRED (positional). Optional deps are functional options:
  svc, _ := authhttp.NewServer(cfg, pg, // pg: your *pgxpool.Pool
    // authhttp.WithRedis(redis),
    // authhttp.WithEmailSender(email), authhttp.WithSMSSender(sms),
  )

  router := gin.New()
  v1 := router.Group("/api/v1")

  authkitgin.RegisterJWKS(router, svc)
  authkitgin.RegisterAPI(v1, svc)
  authkitgin.RegisterOIDC(router, svc, "/oidc")

  router.Run(":8080")
}

AuthKit route specs are prefix-neutral. The host app chooses the mount point: registering RegisterAPI(router.Group("/api/v1"), svc) exposes /api/v1/token, /api/v1/me, and /api/v1/admin/users, while AuthKit's internal route paths remain /token, /me, and /admin/users.

Hosts can mount only selected route groups or wrap individual handlers:

authkitgin.RegisterAPI(v1, svc, authkitgin.WithRoutes(svc.Routes().Groups(
  authhttp.RoutePublic,
  authhttp.RouteSession,
  authhttp.RouteRegister,
  authhttp.RouteUser,
  authhttp.RouteAdmin,
)))

Host-facing JSON API groups are:

  • RoutePublic: public JSON discovery, such as /identity-providers.
  • RouteRegister: public registration and verification support.
  • RouteSession: login, refresh, logout, password reset, login-time 2FA, wallet login.
  • RouteUser: authenticated self-service account routes, step-up, provider linking, wallet linking.
  • RouteAdmin: intrinsic /admin/* root-permission routes.
  • RoutePermissionGroups: generated per-persona group-management routes.

Browser OIDC routes use RouteBrowserOIDC through svc.Routes().OIDCBrowser() and usually mount at /oidc/*. JWKS stays as the separate public svc.JWKSHandler() mount at /.well-known/jwks.json.

For custom routers, iterate svc.Routes().DefaultAPI() or svc.Routes().Groups(...) and register each RouteSpec.Method, RouteSpec.Path, and RouteSpec.Handler yourself. Host apps should not keep duplicated AuthKit route allowlists.

Registration modes and route selection

Route-group selection is the primary host control: a locked-down host should mount only the svc.Routes().Groups(...) subset it intentionally exposes instead of DefaultAPI(). As a defense-in-depth backstop, AuthKit also exposes separate registration modes on core.Config:

  • Registration.NativeUserMode: open, invite_only, admin_only, admin_bootstrap_only, or closed. It defaults to open. Any non-open native-user mode turns off public user self-registration and auto-registration paths: POST /register, /register/availability, /register/resend-email, /register/resend-phone, passwordless auto-registration, OIDC/social/Solana auto-create, and pending-registration confirmation all return a stable registration_disabled error (/register/availability reports every field as unavailable, never usable). Existing-user authentication is unaffected: login, refresh, logout, password reset/recovery, token verification, and sessions all keep working. Embedded bootstrap/admin creation through exported core APIs (CreateUser, ImportUser) still works.

Locked-down (e.g. self-hosted OpenRails) pattern: mount only the chosen route groups, set native registration to admin_bootstrap_only, and bootstrap through embedded core APIs. Bootstrap authority is an operator/deploy action.

cfg := core.Config{
  // ...Token (issuer/audiences) + Keys...
  Registration: core.RegistrationConfig{
    NativeUserMode: core.RegistrationModeAdminBootstrapOnly,
  },
}
svc, _ := authhttp.NewServer(cfg, pg)

// Mount only the route groups this deployment intentionally exposes:
authkitgin.RegisterAPI(v1, svc, authkitgin.WithRoutes(svc.Routes().Groups(
  authhttp.RoutePublic,  // discovery endpoints
  authhttp.RouteSession, // login, refresh, logout, password reset
  authhttp.RouteUser,    // self-service for existing accounts
)))

// Bootstrap declared users/admins internally via AuthKit core APIs
// (unaffected by public registration modes):
core := svc.Core()
_, _ = core.CreateUser(ctx, "ops@example.com", "operator")

For a closed-registration deployment, the bootstrap manifest is the standard machine/bootstrap path. It seeds AuthKit-owned user state and root role assignments, plus trusted remote applications. Root role definitions live in Config.RBAC.Groups, not in the manifest: the only built-in root role is owner (root:*), and applications declare any bounded root roles they need. The manifest does not define personas, permission catalogs, or API keys.

Root startup flow:

  1. Declare root catalog roles in config, for example admin and moderator.
  2. Bootstrap one or more users with root_role: owner.
  3. The owner can then assign configured root roles to other users from an admin dashboard using the generated root member-management routes.
cfg := core.Config{
  // ...Token (issuer/audiences) + Keys...
  RBAC: core.RBACConfig{
    Groups: []core.PersonaDef{
      core.IntrinsicRootPersona(
        core.RoleDef{Name: "admin", Permissions: []string{
          "root:resources:read",
          "root:users:ban",
          "root:users:recover",
          "root:users:delete",
          "root:members:read",
          "root:members:manage",
        }},
        core.RoleDef{Name: "moderator", Permissions: []string{
          "root:resources:read",
          "root:users:ban",
        }},
      ),
    },
  },
}

root:members:manage lets an operator assign/remove root roles. root:roles:manage is the standard role-definition permission for the root persona; root custom-role HTTP routes remain off unless the host explicitly declares the root persona with AllowCustomRoles: true and Routes.CustomRoleCreation: true.

users:
  - email: ops@example.com
    username: operator
    email_verified: true
    password:
      plaintext: "change-this-in-your-secret-renderer"
    root_role: owner

remote_applications:
  - slug: tensorhub-runtime
    issuer: https://tensorhub.example
    jwks_uri: https://tensorhub.example/.well-known/jwks.json
    allowed_origins: ["https://tensorhub.example"]
    enabled: true

group_roles:
  - username: operator
    persona: org
    instance_slug: tensorhub
    role: admin
  - remote_application_slug: tensorhub-runtime
    persona: org
    instance_slug: tensorhub
    role: deployer

See cmd/authkit-devserver/bootstrap.example.yaml for a minimal file matching the current AuthKit manifest shape.

Bootstrap remote applications are root-controlled. Trust is inferred from the source: set jwks_uri for a JWKS URL, or public_keys for static PEM keys. enabled is required so trusting an issuer is explicit. root_role is optional; when set, the remote application is assigned that root role after registration. JWT audiences are host-application verifier config, not remote-application config: the receiving app passes its expected audience once to Verifier.LoadRemoteApplications, and every remote issuer must mint tokens for that audience. group_roles assigns users or remote applications to already-existing permission groups by stable persona + instance_slug; use exactly one of username or remote_application_slug. It does not create host-owned groups or role catalogs. Bootstrap intentionally does not create API keys: those are generated secrets, so hosts should mint and deliver them through their own secret handling.

Bootstrap passwords support three explicit modes: plaintext initial password (hashed by AuthKit), imported hash plus hash_algo, or reset_required: true for imported accounts that must go through recovery before login. Secret references and imported API-key hashes are intentionally not built in; hosts that need Vault/Kubernetes reads should render the manifest or call the library API with their own secret handling.

The standalone AuthKit devserver exposes this as both an opt-in startup hook and an operator apply command. The CLI command applies/reconciles by default:

DEVSERVER_ISSUER=https://auth.example \
DB_URL=postgres://... \
DEVSERVER_PERMISSION_CATALOG=openrails:billing:read,openrails:entitlements:read \
DEVSERVER_API_KEY_PREFIX=cozy \
AUTHKIT_BOOTSTRAP_PATH=/manifests/bootstrap.yaml \
/authkit-devserver bootstrap apply --file /manifests/bootstrap.yaml

This updates declared users, trusted remote applications, and role assignments, but it is not a destructive full sync: omitted users, groups, roles, and remote applications are not deleted.

Startup apply is opt-in and once-only. AUTHKIT_BOOTSTRAP_ON_START=true, CLI --startup-only, or core.ApplyBootstrapManifestFile(ctx, path, core.BootstrapReconcileOptions{StartupOnly: true}) records successful startup bootstrap names in profiles.bootstrap_applies (default when no name is provided). If the marker already exists, the result returns already_applied: true and no state is touched. If users or remote applications already exist but no marker exists, startup apply fails with ErrBootstrapDatabaseNotEmpty instead of unexpectedly rewriting a live system.

Use AUTHKIT_BOOTSTRAP_ON_START=true only for local/dev or simple self-hosted deployments. Production systems should usually run a manual bootstrap apply job, or call core.ApplyBootstrapManifestFile from their own job with host-owned secret handling, so API pods do not need long-lived write credentials.

Hosted SaaS deployments can later set native registration to open and mount the RouteRegister group to enable public signup without code changes.

OpenRails' bootstrap flow should pass its AuthKit-owned user/root-owner seed state through AuthKit bootstrap, then reconcile OpenRails-owned merchants, catalog, prices, entitlements, grants, billing, provider state, and any permission-group credentials itself.

Quick Start (net/http)

package main

import (
  "net/http"

  authhttp "github.com/open-rails/authkit/http"
  core "github.com/open-rails/authkit/core"
)

func main() {
  cfg := core.Config{
    Token: core.TokenConfig{
      Issuer:            "https://myapp.com",
      IssuedAudiences:   []string{"myapp"},
      ExpectedAudiences: []string{"myapp"},
    },
    Frontend: core.FrontendConfig{BaseURL: "https://myapp.com", CallbackPath: "/login/callback"},
  }

  svc, _ := authhttp.NewServer(cfg, pg) // pg: your *pgxpool.Pool (required)
  mux := http.NewServeMux()
  mux.Handle("/.well-known/jwks.json", svc.JWKSHandler())
  mux.Handle("/api/v1/", http.StripPrefix("/api/v1", svc.APIHandler()))
  mux.Handle("/oidc/", svc.OIDCHandler())
  http.ListenAndServe(":8080", mux)
}

Optional Twilio providers

  • Core is provider-agnostic and only depends on core.EmailSender / core.SMSSender.
  • Optional convenience providers are available:
    • github.com/open-rails/authkit/providers/email/twilio for Twilio Email API (SendGrid endpoint).
    • github.com/open-rails/authkit/providers/sms/twilio for Twilio Messaging API.
  • AuthKit never reads provider environment variables directly. Host apps load their own config, build the sender, then pass it as a constructor option (authhttp.WithEmailSender / authhttp.WithSMSSender).
  • The SMS provider requires AccountSID, AuthToken, and MessagingServiceSID. It uses Twilio Messaging (Messages.json) only; there is no Verify service path and no From number fallback path.
  • The email provider requires a SendGrid/Twilio Email API key and a verified from address. AuthKit builds verification/reset links; hosts can provide message builders for branded/localized copy.
  • A 2xx response from AuthKit means the message was accepted by the configured sender/provider submission call. It does not prove the recipient mailbox or carrier ultimately delivered, accepted, opened, or displayed the message.

Preferred language

  • AuthKit stores an optional preferred language on the user profile as a simple two-letter code such as en, es, or zh. Registration seeds it from the request language.
  • Host apps should pass the current site language through AuthKit's language middleware during registration, and should use PATCH /user/preferred-language when the user explicitly changes their account preference.
  • Ordinary login, token refresh, and browsing a different route language must not rewrite the stored preferred language.
  • AuthKit uses the stored language for account, security, verification, password reset, login-code, and welcome messages. Built-in Twilio email/SMS defaults fall back to English when a language is unsupported; host-provided builders can read lang.LanguageFromContext(ctx) for custom localized copy.
  • Site/content language remains host-app owned. Preferred language is the communication language and a default only when the host has no stronger route/session/browser choice.
emailSender, err := emailtwilio.New(emailtwilio.Config{
    APIKey:    cfg.TwilioEmailAPIKey,
    FromEmail: cfg.TwilioEmailFromAddress,
    FromName:  cfg.TwilioEmailFromName,
    AppName:   "Example",
})
if err != nil {
    return err
}
smsSender, err := smstwilio.New(smstwilio.Config{
    AccountSID:          cfg.TwilioAccountSID,
    AuthToken:           cfg.TwilioAuthToken,
    MessagingServiceSID: cfg.TwilioMessagingServiceSID,
    AppName:             "Example",
})
if err != nil {
    return err
}

// Pass the senders as options when constructing the server:
svc, _ := authhttp.NewServer(cfg, pg,
    authhttp.WithEmailSender(emailSender),
    authhttp.WithSMSSender(smsSender),
)

External identity providers

  • Built-in providers (google, apple, discord, github) can still be enabled with core.Config.Providers by passing client IDs/secrets.
  • For custom providers, prefer core.Config.ProviderDescriptors. OIDC providers are usually pure configuration because identity claims are standardized. OAuth2 providers are pure configuration when their userinfo JSON can be mapped with dot paths.
  • Apple uses the same descriptor model, but its client secret is a signed JWT. Use ClientSecret.Strategy: "apple_jwt" with Apple team/key/private-key fields.
  • GET /identity-providers returns the enabled external identity-provider list for frontends.
cfg.ProviderDescriptors = map[string]authprovider.Provider{
    "example-oidc": {
        Name:     "example-oidc",
        Kind:     authprovider.KindOIDC,
        Issuer:   "https://issuer.example",
        ClientID: cfg.ExampleClientID,
        ClientSecret: authprovider.ClientSecret{Env: "EXAMPLE_CLIENT_SECRET"},
        Scopes:   []string{"openid", "email", "profile"},
        PKCE:     true,
    },
    "example-oauth": {
        Name:         "example-oauth",
        Kind:         authprovider.KindOAuth2,
        Issuer:       "https://oauth.example",
        ClientID:     cfg.OAuthClientID,
        ClientSecret: authprovider.ClientSecret{Value: cfg.OAuthClientSecret},
        AuthorizeURL: "https://oauth.example/authorize",
        TokenURL:     "https://oauth.example/token",
        UserInfoURL:  "https://oauth.example/me",
        Scopes:       []string{"profile", "email"},
        PKCE:         true,
        UserMapping: authprovider.UserMapping{
            Subject:           authprovider.FieldMapping{Path: "id", Transforms: []string{"string", "trim"}},
            Email:             authprovider.FieldMapping{Path: "email", Transforms: []string{"trim"}},
            EmailVerified:     authprovider.FieldMapping{Path: "email_verified"},
            PreferredUsername: authprovider.FieldMapping{Path: "username"},
            DisplayName:       authprovider.FieldMapping{Path: "name"},
        },
    },
    "apple": {
        Name:     "apple",
        Kind:     authprovider.KindOIDC,
        Issuer:   "https://appleid.apple.com",
        ClientID: "com.example.web",
        Scopes:   []string{"openid", "email", "name"},
        ExtraAuthParams: map[string]string{"response_mode": "form_post"},
        ClientSecret: authprovider.ClientSecret{
            Strategy: "apple_jwt",
            AppleJWT: &authprovider.AppleJWTSecret{
                TeamID:        cfg.AppleTeamID,
                KeyID:         cfg.AppleKeyID,
                PrivateKeyEnv: "APPLE_PRIVATE_KEY_PEM",
            },
        },
    },
}

Entitlements Provider (Optional)

AuthKit can include entitlements (e.g., "premium", "pro") in service JWTs if you provide an EntitlementsProvider. This is useful for billing/subscription systems where entitlements are stored outside the profiles schema.

Interface:

type EntitlementsProvider interface {
    ListEntitlements(ctx context.Context, userID string) ([]string, error)
}

Providers return active entitlement names only — AuthKit bakes them verbatim into the JWT entitlements claim and admin user views. Filtering expired/revoked grants is the provider's responsibility.

Optionally implement BatchEntitlementsProvider (ListEntitlementsBatch) so AdminListUsers can fetch entitlements in one round trip instead of per row.

Example implementation (querying a billing.entitlements table):

package main

import (
    "context"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

type BillingEntitlementsProvider struct {
    pg *pgxpool.Pool
}

func (p *BillingEntitlementsProvider) ListEntitlements(ctx context.Context, userID string) ([]string, error) {
    rows, err := p.pg.Query(ctx, `
        SELECT entitlement
        FROM billing.entitlements
        WHERE user_id = $1
          AND revoked_at IS NULL
          AND start_at <= $2
          AND (end_at IS NULL OR end_at > $2)
          AND deleted_at IS NULL
    `, userID, time.Now())
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var out []string
    for rows.Next() {
        var name string
        if err := rows.Scan(&name); err != nil {
            return nil, err
        }
        out = append(out, name)
    }
    return out, rows.Err()
}

// Wire it up (Postgres positional; entitlements as an option):
svc, _ := authhttp.NewServer(cfg, pg,
    authhttp.WithEntitlements(&BillingEntitlementsProvider{pg: pg}),
)

Provider failures. A billing outage must not block login: if the provider errors during token issuance, AuthKit still mints the token but omits entitlement claims and logs loudly (token issued WITHOUT entitlement claims). Admin views degrade to no entitlements rather than failing the request.

Gating requests. Use Claims.HasEntitlement(name) for ad-hoc checks, or the RequireEntitlement("premium") / RequireAnyEntitlement("pro", "premium") middleware (mount after Required) to gate routes; both deny API-key principal and delegated tokens, which carry no entitlements.

Snapshot semantics & revocation lag. Entitlements are snapshotted into the JWT at issuance time. Unlike account bans (re-checked live on every request), entitlements are NOT re-validated per request, so a revocation only takes effect once the access token expires or is re-issued. Size your access-token TTL (AccessTokenDuration) to your acceptable entitlement-revocation lag, or re-issue the token when a grant changes.


Concepts (concise)

  • Service (issuer + storage): built by authhttp.NewServer(cfg, pg, opts...) (Postgres required; optional deps are functional options); backs the built-in handlers (sessions, login, OIDC, etc). The full implementation lives in internal/authcore (driven by the HTTP transport and not part of the public contract); embedders reach a small curated facade via svc.Core() (*core.Service) for provisioning, minting, and management - e.g. svc.Core().CreateUser(...), svc.Core().MintServiceJWT(...), svc.Core().ApplyBootstrapManifestFile(...).
  • Middleware: github.com/open-rails/authkit/http provides Required/Optional (JWT verification) plus helpers like RequireAdmin(pg).
  • Verify-only: use authhttp.NewVerifier() + verifier.AddIssuer(...) to accept tokens from other issuers without issuing tokens yourself.
    • Lean import for pure verification: the verification layer (Verifier, NewVerifier, Claims, the Required/Optional middleware, RequiredServiceJWT, etc.) lives in the dependency-light github.com/open-rails/authkit/verify package, which imports no Postgres/Redis/storage — only authkit/jwt + authkit/authbase. A service that only validates tokens (a typical resource server) should import authkit/verify directly to keep pgx/redis out of its build graph. authkit/http re-exports the same names (authhttp.Verifier, authhttp.NewVerifier, authhttp.Claims, …) for apps that also issue tokens, so existing embedders need no changes. Attach DB-backed enrichment (live-user/ban gate, role/email hydration, opaque API-key resolution) only when you want it, via verifier.WithService(coreSvc)*core.Service satisfies the verify.Enricher interface.

Configuration ownership

AuthKit library behavior is host-owned: the embedding app should pass runtime behavior via core.Config, not rely on library env/file reads.

Area Ownership Notes
Issuer, IssuedAudiences, ExpectedAudiences Host config Required token contract inputs.
Registration.Verification, Environment, SolanaNetwork, Registration.NativeUserMode, Frontend.BaseURL Host config Runtime behavior should be deterministic from config.
Keys.Source provided Host config Fully disables library key env/filesystem discovery.
Keys.Source omitted Library exception Only allowed env/filesystem auto-discovery path (ACTIVE_KEY_ID, ACTIVE_PRIVATE_KEY_PEM, PUBLIC_KEYS, <Keys.Path>/keys.json (default /vault/auth), .runtime/authkit/*).
Keys.Path / AUTHKIT_KEYS_PATH Host config Overrides the filesystem directory the local resolver scans for keys.json. Default /vault/auth (unchanged). See "Signing & key resolution for embedders".

Signing & key resolution for embedders

One key per app. Each embedding app owns exactly one JWT signing keypair — its issuer identity, the only thing on its JWKS (plus retiring keys during rotation). That single key signs all of the app's JWTs: user access tokens, first-party service JWTs, delegated access tokens, and remote application access tokens. They differ only in claims (aud, sub/delegated_sub, token_use), never in key. No app should manage a second JWT key.

Sign through AuthKit — the host never holds the private key. The host delegates the signing operation to AuthKit and passes claims/params only. AuthKit exposes the host exactly two things: (1) mint/sign operations (params in → signed token out) and (2) public verification material (JWKS). There is no API that returns a private key, a PEM, or a raw crypto.Signer over the private key — so the host literally cannot read, copy, or persist it. Mint through the *core.Service methods:

svc, _ := core.NewFromConfig(core.Config{
    Token: core.TokenConfig{
        Issuer:            "https://cozy-art.example",
        IssuedAudiences:   []string{"cozy-art"},
        ExpectedAudiences: []string{"cozy-art"},
    },
    // Keys.Source nil => local resolver; point it wherever the host renders keys.json:
    Keys: core.KeysConfig{Path: "/vault/auth"}, // or set AUTHKIT_KEYS_PATH; default is /vault/auth
}, pg) // pg: your *pgxpool.Pool (may be nil for a pure signing/verify-only service)

// Delegated access JWT (cross-service federation) — params only, no key:
tok, _ := svc.MintDelegatedAccessToken(ctx, core.DelegatedAccessParams{
    Audiences:        []string{"tensorhub"},
    DelegatedSubject: userID,
    Permissions:      []string{"repo:create"},
}) // iss defaults to the Service's Issuer

// First-party service JWT (machine-to-machine, e.g. cozy-art -> tensorhub):
sjwt, _, _ := svc.MintServiceJWT(ctx, core.ServiceJWTMintOptions{
    Subject:   "service:cozy-art",
    Audiences: []string{"tensorhub"},
})

// Remote application access token (registered remote_application acting as itself):
rat, _ := svc.MintRemoteApplicationAccessToken(ctx, core.RemoteApplicationAccessParams{
    Audiences: []string{"openrails"},
})

// Arbitrary first-party claims (escape hatch — host owns the claim semantics):
cjwt, _ := svc.MintCustomJWT(ctx, core.CustomJWTMintOptions{
    Type:      "worker-capability+jwt",
    TTL:       10 * time.Minute,
    Subject:   "service:tensorhub",
    Audiences: []string{"cozy.scheduler"},
    Claims: map[string]any{
        "cap_kind":   "worker",
        "grants":     []string{"job:run"},
        "release_id": releaseID,
    },
}) // iss/iat/exp + kid header owned by AuthKit; the host owns everything else

Four mint entry points — pick the most constrained one that fits. All four sign through the one internal key (same JWKS, same kid/alg header); they differ only in how much of the claim shape AuthKit owns:

Method Use when Claim shape
MintServiceJWT First-party machine-to-machine call (service:<app> → another app). Opinionated. Forces token_use=service, typ=service+jwt; you supply sub/aud/permissions/resources only.
MintDelegatedAccessToken Cross-service federation — one issuer signs for a delegated subject a receiver accepts after issuer/JWKS/aud checks. Opinionated. Forces typ=delegated-access+jwt, writes delegated_sub, NEVER sets sub.
MintRemoteApplicationAccessToken Registered remote_application acting as itself through stored AuthKit authority. Opinionated. Forces typ=remote-application-access+jwt, writes neither sub nor delegated_sub; verifier resolves authority from the registered issuer row.
MintCustomJWT Escape hatch — token shapes the two above can't express (e.g. tensorhub capability/worker tokens with cap_kind/grants/release_id, or a worker variant with aud:["cozy.scheduler"]). Host-owned. You pass an arbitrary Claims map (+ optional Type/Subject/Audiences/Issuer). AuthKit owns ONLY iss/iat/exp and the kid/alg header.

MintCustomJWT is the blessed alternative to reaching for the low-level jwtkit.Signer.Sign — the host stops hand-assembling kid/iss/exp and never risks holding a signer it shouldn't. You own the claim semantics; the verifier side must understand them. Precedence is enforced: the host Claims map may NOT set iss/iat/exp (returns ErrCustomClaimsReserved) — iss is overridable only via the explicit Issuer option (defaults to the Service issuer); the explicit Subject/Audiences options win over any sub/aud in the map. TTL is required and capped at MaxCustomJWTLifetime (1h); empty and oversized claim sets are rejected.

Local-backend key resolution precedence (used when cfg.Keys.Source == nil), identical for the convenience auto-resolver and the explicit constructors:

  1. EnvACTIVE_KEY_ID + ACTIVE_PRIVATE_KEY_PEM (+ optional PUBLIC_KEYS).
  2. File<dir>/keys.json where dir = cfg.Keys.PathAUTHKIT_KEYS_PATH/vault/auth (default, unchanged). The file uses the {active_key_id, active_private_key_pem, public_keys} envelope.
  3. Dev-gen — auto-generates and persists a keypair under .runtime/authkit/. Non-prod only: when ENV/APP_ENV/ENVIRONMENT is production/prod and neither env nor file yields a key, resolution hard-fails — no throwaway key in production.

Compose it yourself with the exported jwtkit constructors instead of the convenience resolver:

  • jwtkit.EnvKeySource() — env loader (returns nil when unset).
  • jwtkit.FileKeySource(dir)<dir>/keys.json loader (returns nil when absent; empty dir defaults to /vault/auth).
  • jwtkit.NewGeneratedKeySourceInDir(dir) — dev-gen under a chosen dir (defaults to .runtime/authkit).
  • jwtkit.NewAutoKeySource() / jwtkit.NewAutoKeySourceWithPath(dir) — the composed env → file → dev-gen ladder above.

Pluggable backend (future remote signer). Because jwt.Signer is an interface, the local backend (RSA key in memory, from a KeySource) and a future remote Vault-Transit backend (where the private key never enters the app's memory/disk/container) are interchangeable. AuthKit selects the backend at init; call sites (svc.MintDelegatedAccessToken(...) etc.) are unchanged and never see key material. The remote VaultTransitSigner is tracked as a forward-looking follow-up — authkit future #72 — and drops in behind this same Signer seam with zero host changes.


Notes

  • No extra app code needed for OIDC state or user linking — handled internally with Redis (if provided) or a built-in in-memory cache, plus the default resolver.
  • Apple: prefer a provider descriptor with ClientSecret.Strategy: "apple_jwt" for config-first setup. oidckit.AppleWithKey(...) remains available for code-owned wiring.

Token/session model

  • AuthKit assumes a browser-managed bearer-token model, not cookie sessions.
  • Login, OIDC, Solana, registration confirmation, and refresh flows issue an access_token plus a refresh_token.
  • Browser JavaScript stores those tokens and sends protected API requests with Authorization: Bearer <access_token>.
  • Refresh is also JavaScript-managed: the browser calls POST /token with the refresh token and stores the returned token pair.
  • Full-page OIDC callbacks redirect to {BaseURL}{FrontendCallbackPath} with tokens in the URL fragment (#access_token=...&refresh_token=...) so the host backend serves the frontend route but does not receive the tokens. The default frontend callback path is /login/callback; configured paths must be app-relative, may include a query string, and must not include a fragment.
  • Full-page OIDC login accepts an optional app-relative return_to query parameter, for example /oidc/google/login?return_to=/subscribe?plan=pro. AuthKit stores it in OIDC state and returns it as return_to in the callback fragment after rejecting absolute/external URLs, //host, backslashes, and CR/LF.
  • JSON/SPAs flows such as password login, registration, in-app 2FA, and POST verification/reset return tokens or status in JSON and do not navigate away; the client owns any return_to state there.
  • AuthKit stores refresh-session records server-side for refresh-token lifecycle and revocation, but it does not provide an opaque session_id browser-cookie mode or HttpOnly cookie token mode.
  • Apps that want cookie/session authentication need a separate integration mode: cookie parsing in middleware, CSRF protection, cookie-setting callback behavior, and different frontend refresh/logout assumptions.

Token taxonomy

Credential Wire signal Authority source
User access token JWT typ=access+jwt Local user identity, session id, and authoritative short-lived entitlements in the token; profile and permission-group data comes from /me and live DB lookups.
Delegated access token JWT typ=delegated-access+jwt + delegated_sub Concrete permissions claim, validated against the issuer remote application's stored authority.
Remote application access token JWT typ=remote-application-access+jwt, no sub or delegated_sub Stored authority for the registered remote_application resolved from validated iss.
Service JWT JWT typ=service+jwt + token_use=service Receiver intersects requested permissions/resources with server-side grants for the issuer/subject.
API key Opaque <prefix>_st_<key_id>_<secret> bearer string Stored DB permissions/resources resolved by hashing and looking up the presented secret.

Admin Gate (DB-backed)

  • Use authhttp.RequireAdmin(pg) to strictly enforce admin access using the database.
  • Example:
ver := authhttp.NewVerifier()
ver.AddIssuer("https://my-issuer.com", []string{"my-app"}, authhttp.IssuerOptions{
  JWKSURI: "https://my-issuer.com/.well-known/jwks.json",
})
ver.WithService(coreSvc)

adminHandler := authhttp.Required(ver)(
  authhttp.RequireAdmin(pg)(
    http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      w.WriteHeader(200)
    }),
  ),
)

Roles (global storage)

  • AuthKit stores roles in Postgres profiles.roles and memberships in profiles.user_roles.
  • AuthKit does not define app role taxonomy (what roles exist). The embedding application/platform should seed its role catalog.
  • Role IDs are deterministic UUIDv5 derived from slug (uuidv5(namespace, "role:"+slug)), so role rows are stable across environments.

Permission groups

  • AuthKit exposes instance-scoped membership, roles, and credentials through generated permission-group routes derived from the host's configured schema.
  • Terminology (the vocabulary the API uses consistently):
    • persona — the TYPE of group; also permission segment 1 (merchant, customer).
    • instance — a SPECIFIC group of that persona, addressed by instance_slug (acme-store). Renamed from resource_slug in #135 — instance_slug is the path param and the JSON response field.
    • resource — a noun/area WITHIN a persona; permission segment 2 (subscription).
    • action — permission segment 3 (cancel). So a permission reads persona:resource:action, e.g. merchant:subscription:cancel — the instance (acme-store) never appears in a permission string; it is carried by the group.
    • A merchant persona generates /merchant/:instance_slug/... routes gated by merchant:<resource>:<action> permissions.
  • Adding an EXISTING user to a group is a direct, confirmation-free POST …/members. Bringing in a (possibly not-yet-registered) person uses INVITE LINKS (#134): a manager mints a high-entropy code; the recipient redeems it (signing up first if needed) and the role is assigned. Email-bound links work only for one verified address; shareable links work for anyone with the code, up to max_uses and expiry. Invite-link minting is gated on the registration mode permitting invited signup (open/invite_only).
  • Generated permission-group routes:
    • GET /me/groups
    • GET /me/permissions (effective grants for ?persona= and ?instance=)
    • POST /invites/redeem (any authenticated user redeems an invite-link code)
    • GET /:persona/:instance_slug/members
    • POST /:persona/:instance_slug/members
    • DELETE /:persona/:instance_slug/members/:user
    • PUT /:persona/:instance_slug/members/:user/roles/:role
    • GET /:persona/:instance_slug/roles
    • POST /:persona/:instance_slug/roles
    • DELETE /:persona/:instance_slug/roles/:role
    • GET /:persona/:instance_slug/api-keys
    • POST /:persona/:instance_slug/api-keys
    • DELETE /:persona/:instance_slug/api-keys/:key
    • GET /:persona/:instance_slug/remote-applications
    • POST /:persona/:instance_slug/remote-applications
    • DELETE /:persona/:instance_slug/remote-applications/:app
    • GET /:persona/:instance_slug/invites/links
    • POST /:persona/:instance_slug/invites/links
    • DELETE /:persona/:instance_slug/invites/links/:link
    • Each persona emits only the route families enabled by its management profile. Built-in root emits member-management plus role-list routes.
  • Token claim shape (uniform; no mode):
    • A user access token includes registered JWT claims, sub, sid, and authoritative short-lived entitlements.
    • User access tokens do not include profile snapshots such as email, email_verified, username, or discord_username.
    • Membership, role, permission, and profile data are resolved server-side from /me, route resource state, and stored memberships.

API Keys (opaque machine credentials)

  • Long-lived, revocable shared-secret bearer credentials owned by a permission group, for machine/automation callers (CI, operator CLIs, service-to-service). Robots should not replay the human password-login path. These are symmetric secrets with assigned permissions/resources; they are not JWKS URLs, public keys, or issuer registrations.
  • An API key acts as an API-key principal for its permission group: middleware sets Claims.Permissions, Claims.Resources, and an API-key marker (Claims.IsAPIKey()), leaving UserID empty so the live-user ban/enrichment gate is skipped. Permissions are opaque to authkit; the embedding app owns the vocabulary and enforces meaning.
  • Current wire format is Authorization: Bearer <prefix>_st_<key_id>_<secret>, where <prefix> is the host-configured Config.APIKeys.Prefix brand. key_id is a non-secret public id for indexed lookup; only sha256(secret) is stored; the full key is shown once.
  • Resolved in the Required/Optional middleware before JWT verification (constant-time secret compare; revoked/expired/group-deleted rejected; non-API-key credentials fall through to JWT). The API-key path is separate from the password-login handler, so API keys bypass the interactive password-login rate limiter by design.
  • An API key holds exactly ONE permission-group role: its effective permissions are resolved FROM that role at use time, so editing the role updates every key that holds it. The bespoke-permission use case is served by creating a custom group role. Resource-scope (resources: [{persona,id}]) stays a SEPARATE binding, orthogonal to the role.
  • Mint authorization is native + role-based: minting requires the generated <persona>:credentials:manage permission; the body field is role (a single role slug). AuthKit validates the role exists in the group and enforces no-step-up in core: the creator must hold <persona>:credentials:manage and already cover every permission the API-key role would confer. Permissions are NEVER frozen; they re-resolve from the role at verify time. An API key can never mint/list/revoke API keys because it has no user principal.
  • Resource scopes: API keys may carry opaque host-defined resource rows, resources: [{persona,id}], in addition to permissions. AuthKit validates shape/length and duplicate pairs, stores them in profiles.api_key_resources, and returns them from list/resolve/middleware claims. AuthKit does not interpret resource personas or wildcard-looking IDs; the embedding host owns semantics. Non-empty resource scopes fail closed unless the host sets WithAPIKeyResourceAuthorizer to enforce its resource no-escalation rule. Rule: permissions say what; resources say where.
  • Manage via POST/GET/DELETE /:persona/:instance_slug/api-keys[/:token_id]. POST accepts {name, role, resources?:[{persona,id}], expires_at?}; the mint response also surfaces the role's resolved permissions for convenience. Optional expires_at (null = non-expiring), capped by Config.APIKeys.MaxTTL when set. Stored in profiles.api_keys with permission_group_id plus a role; no per-key permission table.
  • Leak response: revoke the key (DELETE …/api-keys/:id) — the application prefix is registrable with secret-scanning/push-protection partners so leaked keys can be auto-detected.

Service JWTs (OIDC/JWKS machine credentials)

  • First-party services with their own AuthKit issuer/JWKS should prefer short-lived service JWTs over generated opaque API keys. The caller mints a 15-minute JWT with iss, sub, aud, iat, nbf, exp, jti, token_use=service, and permissions: [], caches it in memory until near expiry, and sends it as Authorization: Bearer <jwt>.
  • AuthKit provides core.MintServiceJWT / (*core.Service).MintServiceJWT and authhttp.Verifier.VerifyServiceJWT plus RequiredServiceJWT. Verification uses the same issuer/JWKS registry as delegated access tokens, including remote-application issuer lazy-load and disabled-issuer fail-closed behavior.
  • permissions: [] is the canonical requested-capability claim. OAuth scope is accepted only as an explicit compatibility bridge. AuthKit parses requested permissions/resources but does not grant them; resource servers such as OpenRails must intersect them with server-side grants for the issuer/subject.
  • Use service JWTs for callers that can publish an issuer/JWKS, such as Doujins/Hentai0 -> OpenRails. Use opaque API keys for generated API-key-like credentials, non-OIDC clients, bootstrap scripts, and manual integrations.

Reserved slug policy

  • Owner namespaces reserve user-facing slugs:
    • restricted_name: slug is blocked in profiles.owner_reserved_names and not publicly registrable.
  • There is no public namespace lookup route in the current AuthKit API surface.
  • The PostgreSQL baseline schema creates profiles.owner_reserved_names and seeds canonical restricted names (admin, superuser, root, sudo) directly.
  • Public register and rename paths do not use a hardcoded denylist; conflicts are enforced through owner-namespace uniqueness plus reserved-name table checks.
  • Reserved users are non-loginable (reserved placeholder credentials/providers are cleared by migration and reserve flows).

Verification delivery and expiry

  • Email verification codes and links expire in 60 minutes.
  • Phone/SMS verification codes and links expire in 15 minutes.
  • Password reset link tokens expire in 1 hour.
  • Passwordless login codes and magic links expire in 10 minutes.
  • Sender integrations receive core.VerificationMessage{Code, LinkURL} and must send only provided fields; at least one must be present. Password-reset senders receive the final reset URL, not a raw token.
  • Code-based and link-based flows are both supported:
    • Email verify code: POST /email/verify/confirm with {"email":"user@example.com","code":"123456"}
    • Email verify browser link: GET /email/verify/confirm?token=... redirects to Frontend.VerifyPath; frontend then posts the token.
    • Email verify link token: POST /email/verify/confirm with {"token":"..."}
    • Phone verify code: POST /phone/verify/confirm with {"phone_number":"+1...","code":"123456"}
    • Phone verify browser link: GET /phone/verify/confirm?token=... redirects to Frontend.VerifyPath; frontend then posts the token.
    • Phone verify link token: POST /phone/verify/confirm with {"token":"..."}
    • Email password reset browser link: GET /email/password/reset/confirm?token=... redirects to Frontend.PasswordResetPath; frontend then posts the token and new password.
    • Email password reset confirm: POST /email/password/reset/confirm with {"token":"...","new_password":"..."}
    • Phone password reset browser link: GET /phone/password/reset/confirm?token=... redirects to Frontend.PasswordResetPath; frontend then posts the token and new password.
    • Phone password reset confirm: POST /phone/password/reset/confirm with {"token":"...","new_password":"..."}
    • Passwordless start: POST /passwordless/start with {identifier,email?,phone_number?,mode?,return_to?}; mode is code, link, or omitted for both.
    • Passwordless confirm: POST /passwordless/confirm with {identifier,code} or {token}. Existing contacts log in and are marked verified. If PasswordlessAutoRegistration is enabled and native registration is open, unknown verified contacts create users with generated usernames and no profiles.user_passwords row.
    • Passwordless magic links land on Frontend.PasswordlessPath; only app-relative return_to values are returned with the token response.
  • AuthKit API routes are prefix-neutral. Your API can live under a prefix (recommended: /api/v1); do not add an extra /auth segment when embedding AuthKit.

Identity validation policy

  • AuthKit owns identity validation policy. Host applications should not duplicate or override username, password, email, or phone validation rules.
  • Username rules are fixed: trim whitespace, 4-30 characters, start with an ASCII letter, allow only ASCII letters/digits/underscore, no @, and no leading +. AuthKit normalizes the owner slug by lowercasing and converting underscore/dash runs to single dashes.
  • Username namespace checks reject collisions with users, renamed or recently held slugs, soft-deleted owners, parked namespaces, and restricted names. Parked/restricted names return username_not_allowed; held/taken names return owner_slug_taken.
  • User rename cooldown is fixed at 72 hours. PATCH /user/username returns rename_rate_limited with the shared action-availability fields when blocked (action, allowed, reason, retry_after_seconds, next_allowed_at, cooldown_seconds). time_until_rename_available is still included as a compatibility alias.
  • Password policy is fixed in AuthKit and currently requires at least 8 characters; weak passwords return password_too_short.

Password hash policy (verification whitelist):

  • AuthKit verifies exactly two hash formats: argon2id (native) and bcrypt (legacy-but-sound; verified, then lazily re-hashed to argon2id on the first successful login). This is the designed migration path for hosts importing password hashes: import bcrypt via UpsertPasswordHash, and accounts upgrade themselves transparently.
  • Anything else is deliberately NOT verified, even when an implementation would be easy. Rationale, learned from the doujins legacy migration (1,255 unimportable hashes): DES crypt() truncates passwords to 8 significant characters with a 12-bit salt — accepting a DES match as proof of identity keeps a trivially crackable credential live in the auth path; md5-crypt is fast and memory-unhardened; and corrupted/mangled stored hashes (22% of that cohort) can never verify under any algorithm, so a refuse-and-reset mechanism is needed regardless. A short whitelist is itself the invariant: every additional accepted format lowers the floor of what counts as authentication.
  • For unverifiable imports, hosts store the row with hash_algo = "legacy-reset-required" (core.HashAlgoLegacyResetRequired), preserving the raw legacy hash for forensics only. Every password-verify path (login, step-up, change-password) then returns core.ErrPasswordResetRequired, surfaced over HTTP as a 401 with the stable body code password_reset_required, so clients can tell the user to reset instead of showing generic invalid-credentials.
  • Recovery root of trust: these accounts fall back to email (or phone) mailbox control as the sole proof of ownership — the same trust model as any forgot-password flow (reset links expire in 1 hour, and the flow does not require the address to be pre-verified: receiving the link is the proof). Completing the reset writes an argon2id hash, which clears the flag permanently. Accounts with no reachable email/phone are support cases by design.
  • Email and phone validation/normalization are fixed in AuthKit. Email is trimmed/lowercased and must be address-like. Phone numbers must be E.164-like (+ followed by country code and digits).
  • Shared helpers are exported from core: ValidateUsername, OwnerSlugFromUsername, ValidatePassword, NormalizeEmail, ValidateEmail, NormalizePhone, ValidatePhone, and ValidationErrorCode.

Two-Factor Authentication (2FA):

  • Optional security feature for admin accounts to prevent account takeover if password is leaked.
  • Users can enable multiple primary 2FA factors via email, SMS, or TOTP authenticator-app methods.
  • When enabled, login requires both password AND a 6-digit second-factor code.
  • AuthKit challenges the default factor first and returns available_factors so the frontend can let the user choose another enrolled factor.
  • Each user gets 10 backup codes (8-character alphanumeric) for account recovery in case they lose access to their 2FA method. Backup codes are recovery codes, not primary factors.
  • Login flow with 2FA:
    1. POST /password/login with email/password
    2. If 2FA enabled: response has {"requires_2fa": true, "user_id": "...", "method": "email|sms|totp", "challenge": "...", "default_factor": {...}, "available_factors": [...]}
    3. User receives the default factor's code, or the frontend posts /2fa/challenge with {user_id, challenge, factor_id} to start a different factor.
    4. POST /2fa/verify with {"user_id": "...", "challenge": "...", "factor_id": "...", "code": "123456"} (or {"user_id": "...", "challenge": "...", "code": "ABC123XY", "backup_code": true} for backup codes)
    5. Response contains access_token and refresh_token as usual
  • Setup flow:
    1. GET /user/2fa to check enabled factors, default factor, allowed methods, and backup-code count.
    2. POST /user/2fa with {"method": "email"} to enable email 2FA, {"method": "sms", "phone_number": "+1..."} then {"method": "sms", "phone_number": "+1...", "code": "123456"} for SMS, or {"method": "totp"} then {"method": "totp", "code": "123456"} for TOTP. Adding a factor does not delete other enrolled factors. Add "default": true while enrolling/confirming to make that factor the default, or later post {"factor_id":"...","default":true}.
    3. First enable responses include backup_codes array - show these to user ONCE and tell them to save them
    4. User can regenerate codes with POST /user/2fa/backup-codes (invalidates old codes)
    5. User can delete one factor with DELETE /user/2fa?factor_id=... or disable all 2FA with DELETE /user/2fa
  • Hosts can require 2FA for selected permission-group roles with core.RoleDef{RequiresMFA: true}. AuthKit rejects assigning or accepting that role until the user has account MFA enabled with at least one factor. If the user later disables MFA, AuthKit removes those MFA-required user role assignments.
  • Backup codes are single-use and removed after verification.
  • Server-sent 2FA codes expire in 10 minutes.

Operation:

  • Key rotation is outside the scope of this library and should be handled by your infrastructure (e.g., External Secrets Operator updating mounted secrets, then restarting pods).
  • To rotate keys manually: add the new public key to the map under a new kid, switch the enabled signer, leave the old pub in the map until tokens expire, then remove it.
  • For local development, AuthKit auto-generates keys in .runtime/authkit/ (disabled in production).

Integration requirements (API server)

  • Ephemeral auth state (verification codes, resets, SIWS challenges) uses Redis/Garnet when provided; in dev it falls back to memory.
  • In production, a Redis-compatible store is required.
  • Rate limiting:
    • Enabled by default (in-memory limiter) with per-bucket defaults from authhttp.DefaultRateLimits().
    • Keys: auth:<bucket>:ip:<client-ip>; errors fail-open (request allowed).
    • Default client IP strategy uses the immediate RemoteAddr peer, including private Docker bridge, loopback, and reverse-proxy peers. This keeps anonymous sensitive endpoints protected in local Compose and embedded deployments instead of silently failing open.
    • Request-code and resend buckets default to a 60-second per-client cooldown and 6 requests per hour for registration, registration resend, email/phone verification, password-reset request, and user email/phone change request/resend.
    • 429 responses include one shared action-availability shape for frontend timers: {"error":{"type":"rate_limit_error","code":"rate_limited","message":"Too many requests. Please try again later.","metadata":{"action":"request_email_verification","allowed":false,"reason":"cooldown","retry_after_seconds":N,"next_allowed_at":"...","limit":6,"remaining":5,"window_seconds":3600,"cooldown_seconds":60}}} — the action-availability fields ride in error.metadata.
    • 429 responses also include Retry-After: N plus RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset when the limiter can compute them.
    • Behind reverse proxies, you must explicitly configure trusted proxies to safely use X-Forwarded-For / CF-Connecting-IP. AuthKit will not trust forwarded headers by default (clients can spoof them).
    • For multi-instance production, prefer a Redis/Garnet-backed limiter and a trusted-proxy client IP function, e.g.:
      • authhttp.WithRateLimiter(redislimiter.New(redis, authhttp.ToRedisLimits(authhttp.DefaultRateLimits())))
      • authhttp.WithClientIPFunc(authhttp.ClientIPFromForwardedHeaders(trustedProxyCIDRs)) where trustedProxyCIDRs are the CIDRs of your ingress/proxy layer (nginx, cloudflared, etc.).
    • These are constructor options — pass them to authhttp.NewServer(cfg, pg, ...).
    • Hosts that intentionally want the older public-remote-only fail-open behavior can opt in with authhttp.WithClientIPFunc(authhttp.PublicRemoteAddrClientIP()).
    • To explicitly opt out of rate limiting: authhttp.WithoutRateLimiter().
  • Storage: run the SQL migrations in authkit/migrations/postgres (includes profiles.refresh_sessions).
  • Keys/JWKS: host /.well-known/jwks.json using svc.JWKSHandler() and rotate keys as needed.

AuthKit API route specs, and the APIHandler() net/http compatibility handler built from those same specs, are shown relative to the host-selected API mount prefix. With the recommended /api/v1 mount, GET /me is served at GET /api/v1/me. Browser OIDC routes are served separately and are usually mounted outside API versioning at /oidc/*.

  • GET /.well-known/jwks.json
  • OIDC:
    • GET /oidc/:provider/login?return_to=/app/path
    • GET /oidc/:provider/callback
    • GET /oidc/:provider/step-up/callback
    • POST /oidc/:provider/link/start (RouteUser API group, requires auth) -> {auth_url}
  • Password:
    • POST /password/login (accepts email, phone, or username in identifier field)
    • POST /passwordless/start ({identifier,email?,phone_number?,mode?,return_to?}) -> 202
    • POST /passwordless/confirm ({identifier,code} or {token}) -> {access_token, refresh_token, token_type, expires_in, return_to?}
    • POST /email/password/reset/request
    • GET /email/password/reset/confirm?token=... (browser landing; redirects to Frontend.PasswordResetPath)
    • POST /email/password/reset/confirm ({token, new_password})
  • Registration (unified - accepts email or phone in identifier field):
    • POST /register (server auto-detects email vs phone based on format)
      • Success response includes {ok, username, email, phone_number, discord_username, next_action}
      • next_action is one of none, verify_email, or verify_phone
      • When next_action is none, the response also includes {access_token, refresh_token, token_type, expires_in}
    • Set Registration.Verification: none|optional|required in core.Config. AuthKit's library interface is this tri-state enum (third-party embedders may legitimately want none — no verification artifacts at all). See "Registration verification: the AUTH_REQUIRE_VERIFIED_REGISTRATIONS embedder convention" below for the canonical first-party config knob and the graceful no-sender behavior.
    • POST /register/resend-email
    • POST /register/resend-phone
    • GET /register/availability
    • Registration resend requests now return invalid_email / invalid_phone_number for malformed input and pending_registration_not_found when no matching pending registration exists.
    • Message delivery failures from the configured sender are surfaced as stable email_delivery_failed / sms_delivery_failed errors after AuthKit attempts provider submission.

Registration verification: the AUTH_REQUIRE_VERIFIED_REGISTRATIONS embedder convention

AuthKit's library interface for registration verification is the tri-state enum core.RegistrationVerificationPolicy (none | optional | required), set on core.Config.Registration.Verification. The enum is the stable contract: third-party embedders may legitimately want none (create users immediately, no verification artifacts ever).

First-party / canonical embedders, however, expose one bool knob, not a tri-state enum, so new hosts don't re-invent config names (doujins alone has cycled through AUTH_VERIFICATION_REQUIRED, AUTH_REGISTRATION_VERIFICATION, and back). The recommended convention is:

  • Config key auth.require_verified_registrations / env AUTH_REQUIRE_VERIFIED_REGISTRATIONS
  • Type: bool, default true
  • Mapping, applied at the app's config boundary:
    • truecore.RegistrationVerificationRequired (verification gates login)
    • falsecore.RegistrationVerificationOptional (a verification email/SMS is still sent on signup when a sender is configured, but never blocks login)

This bool intentionally cannot reach none; none stays available only via the raw enum for third-party embedders that want it. (doujins, hentai0, tensorhub, and cozy-art all map the bool at their config boundary.)

Graceful degrade under optional with no sender. If the policy is optional and no email/SMS sender is configured, AuthKit does not error and does not leave the user dangling: it creates the user already verified and sends nothing (the core decision is verified := s.email == nil in CreatePendingRegistrationWithLanguage). So a host can flip AUTH_REQUIRE_VERIFIED_REGISTRATIONS=false before wiring up a mail provider and registration keeps working end-to-end. (required with no sender is rejected at startup by ValidateVerificationConfiguration.)

  • Email verification:
    • POST /email/verify/request
    • GET /email/verify/confirm?token=... (browser landing; redirects to Frontend.VerifyPath)
    • POST /email/verify/confirm ({email, code} or {token})
    • Verification request endpoints return explicit target-state errors: user_not_found, email_already_verified, or phone_already_verified.
  • Phone verification and password reset:
    • POST /phone/verify/request
    • GET /phone/verify/confirm?token=... (browser landing; redirects to Frontend.VerifyPath)
    • POST /phone/verify/confirm ({phone_number, code} or {token})
    • POST /phone/password/reset/request
    • GET /phone/password/reset/confirm?token=... (browser landing; redirects to Frontend.PasswordResetPath)
    • POST /phone/password/reset/confirm ({token, new_password})
  • Sessions:
    • POST /token { grant_type: "refresh_token", refresh_token }
    • POST /sessions/current { refresh_token } → { session_id }
    • GET /user/sessions (requires auth)
    • DELETE /user/sessions/:id (requires auth)
    • DELETE /user/sessions (requires auth)
    • DELETE /logout (requires auth; revokes the current session via sid claim)
  • User profile:
    • GET /me (requires auth)
    • PATCH /user/username (requires auth)
    • POST /email/verify/request (optional auth; with auth starts email change)
    • POST /phone/verify/request (optional auth; with auth starts phone change)
    • PATCH /user/biography (requires auth)
    • POST /user/password (requires auth)
    • DELETE /user (requires auth)
    • DELETE /user/providers/:provider (requires auth)
  • Two-Factor Authentication (2FA):
    • GET /user/2fa (requires auth) → {enabled, method, default_factor, available_factors, backup_codes_remaining}
    • POST /user/2fa (requires auth) → starts or confirms email/SMS/TOTP enrollment; optional default: true; {factor_id, default:true} changes the default
    • DELETE /user/2fa (requires auth) → disables all 2FA, or deletes one factor with factor_id
    • POST /user/2fa/backup-codes (requires auth) → {backup_codes}
    • POST /2fa/challenge (during login) → starts a selected non-default factor from an existing password challenge
    • POST /2fa/verify (during login) → {access_token, refresh_token}
  • Step-up:
    • POST /step-up/password with {password} (requires auth) → {access_token, token_type, expires_in, fresh_auth}
    • POST /step-up/2fa with optional {method:"email|sms|totp", factor_id?} starts selected/default 2FA step-up; final {code, method?, factor_id?, backup_code?} returns {access_token, token_type, expires_in, fresh_auth}.
    • Step-up does not rotate refresh tokens; clients retry sensitive actions with the returned access token. Refresh-token rotation remains POST /token.
  • Admin users (root permission required):
    • GET /admin/users (root:resources:read; query supports root_role and status=deleted)
    • GET /admin/users/:user_id (root:resources:read)
    • POST /admin/users/:user_id/ban (root:users:ban; body requires {until} as RFC3339 or "infinite" and may include {reason})
    • POST /admin/users/:user_id/unban (root:users:ban)
    • POST /admin/users/:user_id/recover (root:users:recover; body has exactly one of {email} or {phone_number}; revokes sessions, deletes password/provider/2FA factors, replaces the primary recovery identifier, and sends a password-reset request)
    • DELETE /admin/users/:user_id (root:users:delete)
    • POST /admin/users/:user_id/restore (root:users:delete)
    • GET /admin/users/:user_id/signins (root:resources:read)
    • POST /admin/users/:user_id/sessions/revoke (root:users:recover)
  • Solana wallet authentication (SIWS):
    • POST /solana/challenge → {domain, address, nonce, issuedAt, expirationTime, ...}
    • POST /solana/login → {access_token, refresh_token, user}
    • POST /solana/link (requires auth) → {success, solana_address}

Expired Token/Code Cleanup

AuthKit deletes verification codes when they're consumed. Expired codes are not auto‑purged. Operators should periodically delete expired rows. Example SQL:

-- Remove expired email verification codes
DELETE FROM profiles.email_verifications WHERE expires_at <= now();

-- Remove expired password reset codes
DELETE FROM profiles.password_resets WHERE expires_at <= now();

-- Remove expired phone verification codes (registration + password reset)
DELETE FROM profiles.phone_verifications WHERE expires_at <= now();

-- Remove expired pending registrations
-- Pending registrations now live in Redis/Garnet; no SQL cleanup needed.
DELETE FROM profiles.pending_phone_registrations WHERE expires_at <= now();

-- 2FA verification codes live in Redis/Garnet or the in-memory ephemeral store;
-- no SQL cleanup is needed.

Run these from your scheduler (cron, pg_cron, or your job system).


Frontend (React) quick guide

  • Paths below are relative to the AuthKit API mount. In doujins/hentai0-style hosts mounted at /api/v1, call /api/v1/token, /api/v1/me, /api/v1/admin/users, etc.
  • Tokens
    • Store access_token in memory and refresh_token in IndexedDB/secure storage.
    • Add Authorization: Bearer <access_token> to protected API calls. On 401, call POST /token with refresh_token, then retry.
  • Registration (unified)
    • POST /register with {identifier, username, password} where identifier is email or phone
    • On success, branch on next_action: none, verify_email, or verify_phone
    • If next_action is none, store the returned access/refresh tokens immediately; do not replay the password
    • Email registration: check email for 6-char code → POST /email/verify/confirm with {email, code}
    • Phone registration: check SMS for 6-char code → POST /phone/verify/confirm with {phone_number, code}
    • Successful email/phone code or link confirmation returns access/refresh tokens
    • Resend codes: POST /register/resend-email or POST /register/resend-phone
  • Password Login
    • POST /password/login with {login, password} where login can be email/phone/username → {id_token, refresh_token}
  • Password Reset
    • POST /email/password/reset/request with {email} → check email for reset instructions
    • POST /email/password/reset/confirm with {token, new_password} → {ok: true}
    • POST /phone/password/reset/request with {phone_number} → check SMS for reset instructions
    • POST /phone/password/reset/confirm with {token, new_password} → {ok: true}
  • OIDC
    • Start: window.location = /oidc/${provider}/login.
    • Link: POST /api/v1/oidc/:provider/link/start (with Authorization) → {auth_url}; then window.location = auth_url.
  • Unlink
    • DELETE /user/providers/:provider (Authorization). Guard prevents unlinking the last login method.
  • Sessions
    • DELETE /logout (current), DELETE /user/sessions (all), DELETE /user/sessions/:id (single), GET /user/sessions (list).
    • POST /sessions/current with {refresh_token} → {session_id}.
  • Current user
    • GET /me → {id, email, pending_email?, phone_number?, username, user_aliases?, discord_username?, email_verified, phone_verified, has_password, roles, entitlements, biography, preferred_language?}.
    • Email change
      • POST /email/verify/request with {email,password?} (Authorization) → sends verification code and link to the new email
      • POST /email/verify/confirm with {email,code} (Authorization) or {token} → confirms email change
    • Phone number change
      • POST /phone/verify/request with {phone_number,password?} (Authorization) → sends verification code and link to the new phone
      • POST /phone/verify/confirm with {phone_number,code} (Authorization) or {token} → confirms phone number change
  • User profile updates
    • PATCH /user/username with {username} (Authorization)
    • PATCH /user/preferred-language with {preferred_language} (Authorization)
    • PATCH /user/biography with {biography} (Authorization)
    • POST /user/password with {old_password, new_password} (Authorization)
    • DELETE /user (Authorization) → deletes account
    • Sensitive-action step_up_required errors include step_up_methods and, when 2FA is enabled, step_up_2fa with available methods/default method/display-safe destinations. Call /step-up/password or /step-up/2fa, replace the in-memory access token with the returned access_token, then retry. Do not call /token just to finish step-up.
  • Solana Wallet (SIWS)
    • Login/Register: POST /solana/challenge → wallet.signIn(input) → POST /solana/login
    • Link wallet: POST /solana/challenge → wallet.signIn(input) → POST /solana/link (with Authorization)

Solana Wallet Authentication (SIWS)

Sign In With Solana allows users to authenticate using their Solana wallet (Phantom, Solflare, Backpack, etc.). Users can create accounts with just a wallet (no email/password required) or link a wallet to an existing account.

Frontend Integration (React/TypeScript):

import { useWallet } from '@solana/wallet-adapter-react';

// 1. Request challenge from backend
const requestChallenge = async (address: string, username?: string) => {
  const response = await fetch('/api/v1/solana/challenge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ address, username }), // username optional for new accounts
  });
  return response.json(); // Returns SignInInput
};

// 2. Sign with wallet
const signIn = async () => {
  const { publicKey, signIn } = useWallet();
  if (!publicKey || !signIn) return;

  // Get challenge from backend
  const input = await requestChallenge(publicKey.toBase58(), 'desired_username');

  // Wallet prompts user to sign
  const output = await signIn(input);

  // 3. Verify signature and get tokens
  const response = await fetch('/api/v1/solana/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      output: {
        account: { address: output.account.address },
        signature: btoa(String.fromCharCode(...output.signature)),
        signedMessage: btoa(String.fromCharCode(...output.signedMessage)),
      },
    }),
  });

  const { access_token, refresh_token, user } = await response.json();
  // Store tokens as usual
};

Link wallet to existing account:

const linkWallet = async (accessToken: string) => {
  const { publicKey, signIn } = useWallet();
  if (!publicKey || !signIn) return;

  // Get challenge
  const input = await requestChallenge(publicKey.toBase58());

  // Sign
  const output = await signIn(input);

  // Link (requires auth)
  const response = await fetch('/api/v1/solana/link', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      output: {
        account: { address: output.account.address },
        signature: btoa(String.fromCharCode(...output.signature)),
        signedMessage: btoa(String.fromCharCode(...output.signedMessage)),
      },
    }),
  });

  return response.json(); // { success: true, solana_address: "..." }
};

Notes:

  • Challenges expire in 15 minutes
  • Username is optional - if not provided, a username is derived from the wallet address (e.g., u_7xKX)
  • Users can change their username later via PATCH /user/username
  • Wallet address is stored as a provider link (like Google/Discord) in profiles.user_providers
  • One wallet per user, one user per wallet

Verifier (JWKS, verify‑only)

Use the verifier when a service needs to accept JWTs issued by one or more AuthKit-powered APIs (e.g., spacex), without mounting any auth routes.

  • Create with authhttp.NewVerifier(opts...) — options: WithSkew, WithAlgorithms, WithHTTPClient.
  • Add issuers via verifier.AddIssuer(issuerID, audiences, opts) — each may specify a JWKS URL (defaults to /.well-known/jwks.json), pre-provided PEM keys, or raw *rsa.PublicKey maps.
  • For service JWTs, call verifier.VerifyServiceJWT(ctx, token) or mount authhttp.RequiredServiceJWT(verifier). This returns a machine principal with issuer, subject, remote-application slug, permissions, resources, and JTI; the host still owns final authorization.
  • Keep route classes explicit: ordinary user/delegated routes use authhttp.Required, delegated-only resource routes use verifier.VerifyDelegatedAccess, and first-party machine routes use authhttp.RequiredServiceJWT. Service JWTs are intentionally rejected by the ordinary/delegated entry points, and user/delegated JWTs are intentionally rejected by RequiredServiceJWT.
  • Default skew: 60s. Default algorithms: RS256.
  • DB enrichment (recommended):
    • Call verifier.WithService(coreSvc) to enable best-effort DB enrichment hooks (roles + canonical email + provider usernames) when the token lacks those claims.

Accepting Tokens From Multiple Issuers

SpaceX accepts JWTs from multiple issuers; both tesla.com and x.com.


  import (
    "encoding/json"
    "net/http"
    authhttp "github.com/open-rails/authkit/http"
    "time"
  )

  func main() {
    ver := authhttp.NewVerifier(authhttp.WithSkew(60 * time.Second))
    ver.AddIssuer("https://tesla.com", []string{"spacex-app"}, authhttp.IssuerOptions{})
    ver.AddIssuer("https://x.com", []string{"spacex-app"}, authhttp.IssuerOptions{})

    mux := http.NewServeMux()

    // (1) Claims-only: just check JWT (no DB). 401 if missing/invalid.
    mux.Handle("/claims-only", authhttp.Required(ver)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      cl, ok := authhttp.ClaimsFromContext(r.Context())
      if !ok {
        w.WriteHeader(401)
        return
      }
      _ = json.NewEncoder(w).Encode(map[string]any{"user_id": cl.UserID})
    })))

    // (4) Admin-only: require login, then check admin role directly via DB.
    mux.Handle("/admin/report", authhttp.Required(ver)(authhttp.RequireAdmin(pg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      w.WriteHeader(200)
    }))))

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

Remote Application Issuers & Delegated Access JWTs

AuthKit owns the shared identity primitives for federation: a resource service registers remote applications, verifies their OIDC/JWKS metadata, and accepts delegated users as (issuer, delegated_sub). Product-specific approval, quota, billing, and resource policy still belong to the receiving product.

This lets an external system bring principals that live in its own identity store. Those principals authenticate through the remote application's issuer rather than local passwords. Two AuthKit-embedding services register with and trust each other:

  • the platform / IdP side (e.g. cozy-art) mints delegated tokens and sends its remote-application issuer registration;
  • the resource-server side (e.g. tensorhub) accepts registrations and validates delegated tokens.

There are three roles, all owned by AuthKit:

Role Side API
register both RemoteApplicationIssuersClient.RegisterIssuer (outbound) -> POST /:persona/:instance_slug/remote-applications (inbound)
mint platform MintDelegatedAccessToken(ctx, signer, DelegatedAccessParams)
validate resource server Verifier.LoadRemoteApplications + Verifier.VerifyDelegatedAccess -> Claims.DelegatedAccess()

Delegated access JWTs

A delegated access JWT is AuthKit's standard primitive for federation: one AuthKit issuer signs a short-lived JWT for an external delegated subject, and a resource service (OpenRails, Tensorhub, Gen-Orchestrator, ...) accepts it after issuer/JWKS/audience validation. Mint it with MintDelegatedAccessToken / DelegatedAccessParams.

Canonical claim contract:

Claim Meaning Typed accessor
header typ=delegated-access+jwt identifies a delegated access JWT (DelegatedAccessTokenType) Claims.TokenTyp / IsDelegatedAccessToken()
iss AuthKit issuer that signed the token Claims.Issuer
aud target resource API (openrails, tensorhub, gen-orchestrator) matched at verify
delegated_sub issuer-side subject id; no local account is implied Claims.DelegatedSubject
permissions []string resource-defined permission strings, not OAuth scope Claims.Permissions / HasPermission()
attributes {} issuer policy metadata, e.g. {"tier":"cozy_free"} Claims.Attributes / Attribute(key)
iat/exp/nbf/jti standard timing + token id Claims.JTI

Hard invariants (enforced + tested):

  • Ordinary AuthKit access JWTs use header typ=access+jwt; delegated access tokens use header typ=delegated-access+jwt. Verify() rejects missing, unknown, or cross-profile typ values.
  • A delegated access JWT must not carry a normal sub; the receiving service authorizes by trusted issuer plus delegated subject, not by a local user row.
  • roles are not a top-level delegated access JWT claim. Receiving services authorize on permissions plus explicit attributes policy.
  • Tier/plan metadata belongs under attributes.tier; a top-level user_tier claim is rejected.
  • Remote applications loaded from AuthKit's registry are bound to the permission group that registered them; downstream authorization should intersect token permissions with that stored authority.

Receiving services can install validation hooks:

v := authhttp.NewVerifier(
    authhttp.WithPermissions(func(perms []string) error { /* check permissions */ }),
    authhttp.WithAttributesPolicy(func(a map[string]json.RawMessage) error { /* check schema */ }),
)
cl, dp, err := v.VerifyDelegatedAccess(token) // requires typ=delegated-access+jwt + runs hooks
// dp.Issuer, dp.DelegatedSubject, dp.Permissions, dp.Attributes, dp.JTI

Because a delegated access JWT has no sub, the resource server's middleware skips the local-user gate. Authorization is by issuer trust plus permissions, not local-user existence.

For browser-direct self-service billing, the host app still has one authenticated AuthKit touchpoint: a current-user token endpoint owned by the host app. That endpoint authenticates the normal app session, decides which self-scoped OpenRails permissions the current user may receive, then calls MintDelegatedAccessToken with aud=openrails, delegated_sub set to the current user id, short TTL, and permissions such as openrails:self:billing:read or openrails:self:checkout:create. The browser then calls OpenRails directly with that delegated access JWT; the host does not proxy billing reads or checkout/subscription actions.

Registration handshake

Outbound (platform side, e.g. cozy-art) — publish this remote application's issuer + JWKS URL to a resource server's accept endpoint:

fc := authhttp.NewRemoteApplicationIssuersClient(
    authhttp.WithRemoteApplicationIssuersAuthToken(ownerAccessToken),
)
err := fc.RegisterIssuer(ctx, "https://tensorhub.example/api/v1/remote-applications",
    authhttp.RemoteApplicationIssuerRegistration{
        Slug:           "cozy-art",
        Issuer:         "https://cozy.art",
        JWKSURI:        "https://cozy.art/.well-known/jwks.json",
        AllowedOrigins: []string{"https://cozy.art"},
    })

AllowedOrigins is an exact browser-Origin allow-list for delegated browser requests signed by that issuer. CORS preflight can only use the union of enabled remote-application origins because it has no JWT; mount authhttp.RequireDelegatedOrigin after authhttp.Required to enforce the real request's Origin against the verified token issuer.

Inbound (resource-server side, e.g. tensorhub) — use the generated remote-application management routes. POST /:persona/:instance_slug/remote-applications accepts and stores a registration authorized by the controlling permission group; DELETE /:persona/:instance_slug/remote-applications/:app removes one; GET /:persona/:instance_slug/remote-applications lists that group's apps.

In-house JWKS — no external push/sync

The resource server loads registered remote applications from AuthKit's own store and registers each with the Verifier, whose existing in-house JWKS fetch/refresh then handles the keys. There is no external key push or sync; the resource server pulls JWKS from each issuer's URL on demand and refreshes per CacheTTL.

// At startup (and re-run on a ticker / after a registration) to pick up store changes:
err := verifier.LoadRemoteApplications(ctx, coreSvc, []string{"tensorhub"})

The audience argument is the receiving host application's expected audience, applied to every loaded remote issuer. It is not stored on the remote application row and should not vary by JWKS issuer.

LoadRemoteApplications registers only enabled issuers. A newly-accepted registration is also added to the Verifier immediately by the inbound handler, so it is usable without waiting for the next store load.

Documentation

Overview

Package authkit holds authentication primitives shared between authkit's issuing core and its verification layer: plain data types, opaque-credential parsing, and sentinel errors that carry NO dependency on Postgres or the rest of core (stdlib only). It exists so the verification path — and, later, a standalone verify module (agents #110/#107) — can depend on these without pulling in the storage layer. The core package re-exports every symbol here as an alias, so existing callers using core.X are unaffected.

Index

Constants

View Source
const (
	ImportStatusInserted   ImportUserStatus = "inserted"
	ImportStatusSkipped    ImportUserStatus = "skipped"
	ImportStatusRejected   ImportUserStatus = "rejected"
	AdminUserStatusActive  AdminUserStatus  = "active"     // not deleted, not banned
	AdminUserStatusBanned  AdminUserStatus  = "banned"     // not deleted, currently banned
	AdminUserStatusDeleted AdminUserStatus  = "deleted"    // soft-deleted
	AdminUserStatusAny     AdminUserStatus  = "any"        // no deleted/banned predicate
	AdminUserSortCreatedAt AdminUserSort    = "created_at" // default
	AdminUserSortLastLogin AdminUserSort    = "last_login"
	AdminUserSortUsername  AdminUserSort    = "username"
	AdminUserSortEmail     AdminUserSort    = "email"
)
View Source
const (
	ErrorTypeInvalidRequest = "invalid_request_error"
	ErrorTypeAuthentication = "authentication_error"
	ErrorTypeAuthorization  = "authorization_error"
	ErrorTypeRateLimit      = "rate_limit_error"
	ErrorTypeAPI            = "api_error"
)

Error type categories, aligned with openrails' / Stripe's taxonomy strings.

View Source
const (
	RemoteAppModeJWKS   = "jwks"
	RemoteAppModeStatic = "static"
)

Remote-application trust modes (#74). A remote_application is a federation PRINCIPAL whose credential is a key, with exactly one trust source:

jwks   — keys fetched + refreshed from JWKSURI; rotation is publishing a new
         kid at the same URL.
static — authorized_keys-style human-managed PEM list for principals without
         a JWKS endpoint; manual rotation by design.
View Source
const (
	// ServiceJWTTokenUse is the required `token_use` claim for service JWTs.
	ServiceJWTTokenUse = "service"
	// DefaultServiceJWTLifetime is the recommended lifetime for first-party
	// machine-to-machine service JWTs.
	DefaultServiceJWTLifetime = 15 * time.Minute
)
View Source
const PermWildcard = "*"

PermWildcard is the wildcard CHARACTER used inside namespace-anchored globs (`org:*`, `org:members:*`, `org:*:read`, `root:*`). A bare standalone `*` is NOT a valid grant — it is rejected everywhere.

Variables

View Source
var (
	// ErrInvalidAccessToken indicates an API key that does not exist, has a bad
	// secret, or whose owning permission group is gone. Deliberately indistinguishable from
	// a malformed token so callers learn nothing from the error.
	ErrInvalidAccessToken = errors.New("invalid_token")
	// ErrAccessTokenRevoked indicates the API key was explicitly revoked.
	ErrAccessTokenRevoked = errors.New("token_revoked")
	// ErrAccessTokenExpired indicates the API key is past its expires_at.
	ErrAccessTokenExpired = errors.New("token_expired")
)
View Source
var (
	ErrBootstrapDatabaseNotEmpty       = errors.New("bootstrap_database_not_empty")
	ErrCannotRemoveLastAdminRole       = errors.New("cannot_remove_last_admin_role")
	ErrCustomClaimsReserved            = errors.New("custom_jwt_reserved_claim")
	ErrCustomJWTReservedType           = errors.New("custom_jwt_reserved_type")
	ErrEmailAlreadyVerified            = errors.New("email_already_verified")
	ErrEmailDeliveryFailed             = errors.New("email_delivery_failed")
	ErrEmailInUse                      = errors.New("email_in_use")
	ErrEmailSenderUnavailable          = errors.New("email_sender_unavailable")
	ErrEmptyCustomClaims               = errors.New("custom_jwt_empty_claims")
	ErrEntitlementFilterUnavailable    = errors.New("authkit: entitlement filtering requires an EntitlementFilterProvider")
	ErrExternalInvitesDisabled         = errors.New("external_invites_disabled")
	ErrGroupNotFound                   = errors.New("permission_group_not_found")
	ErrInsufficientRoleAuthority       = errors.New("insufficient_role_authority")
	ErrInvalidAttributeDef             = errors.New("invalid_attribute_def")
	ErrInvalidBootstrapManifest        = errors.New("invalid_bootstrap_manifest")
	ErrInvalidUntil                    = errors.New("invalid_until")
	ErrInviteEmailMismatch             = errors.New("group_invite_email_mismatch")
	ErrInviteLinkExhausted             = errors.New("group_invite_link_exhausted")
	ErrInviteLinkExpired               = errors.New("group_invite_link_expired")
	ErrInviteLinkNotFound              = errors.New("group_invite_link_not_found")
	ErrInviteLinkRevoked               = errors.New("group_invite_link_revoked")
	ErrMissingSigner                   = errors.New("missing_signer")
	ErrNotGroupMember                  = errors.New("not_group_member")
	ErrOwnerSlugTaken                  = errors.New("owner_slug_taken")
	ErrPasskeyCloneDetected            = errors.New("passkey_clone_detected")
	ErrPasskeyNotFound                 = errors.New("passkey_not_found")
	ErrPasskeyUserVerificationRequired = errors.New("passkey_user_verification_required")
	ErrPasswordlessDisabled            = errors.New("passwordless_disabled")
	ErrPasswordResetRequired           = errors.New("password_reset_required")
	ErrPendingRegistrationNotFound     = errors.New("pending_registration_not_found")
	ErrPhoneAlreadyVerified            = errors.New("phone_already_verified")
	ErrPhoneInUse                      = errors.New("phone_in_use")
	ErrRegistrationDisabled            = errors.New("registration_disabled")
	ErrRemoteApplicationNotFound       = errors.New("remote_application_not_found")
	ErrRenameRateLimited               = errors.New("rename_rate_limited")
	ErrReservedIssuer                  = errors.New("reserved_issuer")
	ErrResourceScopeDenied             = errors.New("resource_scope_denied")
	ErrRoleAssignmentEscalation        = errors.New("role_assignment_escalation")
	ErrSMSDeliveryFailed               = errors.New("sms_delivery_failed")
	ErrSMSSenderUnavailable            = errors.New("sms_unavailable")
	ErrStepUpRequired                  = errors.New("step_up_required")
	ErrTooManyCustomClaims             = errors.New("custom_jwt_too_many_claims")
	ErrTwoFAEnrollmentRequired         = errors.New("2fa_enrollment_required")
	ErrUserBanned                      = errors.New("user_banned")
	ErrUserNotFound                    = errors.New("user_not_found")
	ErrUserRoleNotFound                = errors.New("user_role_not_found")
	ErrVerificationLinkExpired         = errors.New("verification_link_expired")
)

Sentinel errors — the wire-contract error identities shared by the embedded engine and (Phase 2) the remote SDK so errors.Is works across transports (#138 contract inversion). internal/authcore aliases these.

View Source
var ErrAttributeDefNotFound = errors.New("attribute_def_not_found")

ErrAttributeDefNotFound indicates no registered remote-application attribute definition matched.

View Source
var ErrInvalidRemoteApplication = errors.New("invalid_remote_application")

ErrInvalidRemoteApplication indicates a malformed remote_application registration payload (including invalid allowed-origin values).

View Source
var ErrInvalidServiceJWT = errors.New("invalid_service_jwt")

ErrInvalidServiceJWT indicates a presented service JWT failed verification.

Functions

func APIKeyMarker

func APIKeyMarker(prefix string) string

APIKeyMarker returns the leading marker that identifies an API key for the given application prefix: "<prefix>_st_" when prefix is non-empty, else "st_".

func ErrorForCode added in v0.68.0

func ErrorForCode(code string) error

ErrorForCode maps a wire error code (a sentinel's Error() string) back to the sentinel, so a remote client re-derives errors.Is(err, authkit.ErrX) identity across the network. Unknown/empty codes return nil — the caller supplies its own fallback. The server emits err.Error() as the code; remote/ resolves it here, so the wire-error contract has ONE source of truth (#142).

func ErrorMessage

func ErrorMessage(code string) string

ErrorMessage returns a human-readable English message for a wire error code: a curated message for common codes, otherwise a humanized form of the code so the message is never empty. Localized catalogs are a future extension.

func ErrorTypeForStatus

func ErrorTypeForStatus(status int) string

ErrorTypeForStatus maps an HTTP status to its error-type category (the same inference openrails performs).

func FormatAPIKey

func FormatAPIKey(prefix, keyID, secret string) string

FormatAPIKey assembles the full presented token: <marker><key_id>_<secret>.

func HasAPIKeyPrefix

func HasAPIKeyPrefix(prefix, token string) bool

HasAPIKeyPrefix reports whether token carries the API-key marker for prefix. Used by middleware to route to the API-key path before attempting JWT verification.

func NormalizeAllowedOrigin

func NormalizeAllowedOrigin(origin string) (string, error)

NormalizeAllowedOrigin validates one browser Origin value and returns its canonical exact-match form. It accepts only scheme+host(+port), never paths, queries, fragments, userinfo, wildcards, or the special "null" origin.

func NormalizeAllowedOrigins

func NormalizeAllowedOrigins(origins []string) ([]string, error)

NormalizeAllowedOrigins validates, trims, normalizes, and de-duplicates exact browser origins for a remote_application.

func OriginAllowed

func OriginAllowed(origin string, allowedOrigins []string) bool

OriginAllowed reports whether origin exactly matches one of allowedOrigins.

func ParseAPIKey

func ParseAPIKey(prefix, token string) (keyID, secret string, ok bool)

ParseAPIKey splits a presented token into its key_id and secret. key_id and secret are base62 (no underscores), so the first "_" after the marker is the unambiguous delimiter. ok is false if the token lacks the marker or either part is empty.

func PermMatches

func PermMatches(grant, concrete string) bool

PermMatches reports whether a GRANT token authorizes a CONCRETE permission. The grant may be a literal (`org:members:read`) or a namespace-anchored glob where `*` wildcards a whole segment (`org:members:*`, `org:*:read`, `org:*`). The namespace (segment 0) must be a literal — a bare `*` (or a `*` namespace) never matches. A two-segment glob `ns:*` matches every concrete `ns:…` perm.

This is the shared, authz-critical matcher used by both core's RBAC checks and the verification layer's permission-coverage checks.

func PermissionTokenCovers

func PermissionTokenCovers(grant, requested string) bool

PermissionTokenCovers reports whether a stored grant token covers a requested permission token using AuthKit's namespace-anchored glob semantics.

Types

type APIKey

type APIKey struct {
	ID          string
	KeyID       string
	Name        string
	Role        string
	Permissions []string
	Resources   []APIKeyResource
	CreatedBy   string
	CreatedAt   time.Time
	LastUsedAt  *time.Time
	ExpiresAt   *time.Time
	RevokedAt   *time.Time
}

type APIKeyMintOptions

type APIKeyMintOptions struct {
	Name      string
	Role      string
	Resources []APIKeyResource
	CreatedBy string
	ExpiresAt *time.Time
}

type APIKeyResource

type APIKeyResource struct {
	Persona string `json:"persona"`
	ID      string `json:"id"`
}

APIKeyResource is one opaque, host-defined resource scope carried by an API key: a {Persona, ID} reference to a permission-group instance. AuthKit stores and returns the exact pair but does not interpret it — hosts own resource semantics, including any wildcard-looking IDs such as "*".

type AdminListUsersResult

type AdminListUsersResult struct {
	Users  []AdminUser `json:"users"`
	Total  int64       `json:"total"`
	Limit  int         `json:"limit"`
	Offset int         `json:"offset"`
}

type AdminUser

type AdminUser struct {
	ID              string     `json:"id"`
	Email           *string    `json:"email"` // Nullable for phone-only users
	PhoneNumber     *string    `json:"phone_number"`
	Username        *string    `json:"username"`
	DiscordUsername *string    `json:"discord_username"`
	EmailVerified   bool       `json:"email_verified"`
	PhoneVerified   bool       `json:"phone_verified"`
	BannedAt        *time.Time `json:"banned_at,omitempty"`
	BannedUntil     *time.Time `json:"banned_until,omitempty"`
	BanReason       *string    `json:"ban_reason,omitempty"`
	BannedBy        *string    `json:"banned_by,omitempty"`
	DeletedAt       *time.Time `json:"deleted_at"`
	Biography       *string    `json:"biography"`
	CreatedAt       time.Time  `json:"created_at"`
	UpdatedAt       time.Time  `json:"updated_at"`
	LastLogin       *time.Time `json:"last_login"`
	Roles           []string   `json:"roles"`
	Entitlements    []string   `json:"entitlements"`
}

type AdminUserListOptions

type AdminUserListOptions struct {
	Page        int
	PageSize    int
	Search      string          // ILIKE over username/email/phone_number
	Role        string          // root_role slug (e.g. "admin"); empty = no role filter
	Status      AdminUserStatus // empty = non-deleted (historical default)
	Sort        AdminUserSort   // empty = created_at
	Desc        bool            // true = descending
	Entitlement string          // empty = no entitlement filter; else provider-backed
}

type AdminUserSort

type AdminUserSort string

type AdminUserStatus

type AdminUserStatus string

type Authorizer added in v0.67.0

type Authorizer interface {
	Can(ctx context.Context, subjectID, subjectKind, persona, instanceSlug, perm string) (bool, error)
	ListEffectivePermissions(ctx context.Context, subjectID, subjectKind, persona, instanceSlug string) ([]string, error)
	IsUserAllowed(ctx context.Context, userID string) (bool, error)
	ListRoleSlugsByUserErr(ctx context.Context, userID string) ([]string, error)
}

Authorizer is the "can this subject do X here" slice: permission checks, the live-user/ban gate, and role resolution. doujins's request gate depends on this.

type BootstrapManifest

type BootstrapManifest struct {
	Users              []BootstrapManifestUser              `json:"users" yaml:"users"`
	RemoteApplications []BootstrapManifestRemoteApplication `json:"remote_applications" yaml:"remote_applications"`
	GroupRoles         []BootstrapManifestGroupRole         `json:"group_roles" yaml:"group_roles"`
}

type BootstrapManifestGroupRole

type BootstrapManifestGroupRole struct {
	Username              string `json:"username" yaml:"username"`
	RemoteApplicationSlug string `json:"remote_application_slug" yaml:"remote_application_slug"`
	Persona               string `json:"persona" yaml:"persona"`
	InstanceSlug          string `json:"instance_slug" yaml:"instance_slug"`
	Role                  string `json:"role" yaml:"role"`
}

type BootstrapManifestRemoteApplication

type BootstrapManifestRemoteApplication struct {
	Slug           string         `json:"slug" yaml:"slug"`
	Issuer         string         `json:"issuer" yaml:"issuer"`
	JWKSURI        string         `json:"jwks_uri" yaml:"jwks_uri"`
	PublicKeys     []RemoteAppKey `json:"public_keys" yaml:"public_keys"`
	AllowedOrigins []string       `json:"allowed_origins" yaml:"allowed_origins"`
	Enabled        *bool          `json:"enabled" yaml:"enabled"`
	RootRole       string         `json:"root_role" yaml:"root_role"`
}

type BootstrapManifestResult

type BootstrapManifestResult struct {
	DryRun               bool `json:"dry_run"`
	AlreadyApplied       bool `json:"already_applied"`
	UsersCreated         int  `json:"users_created"`
	UsersUpdated         int  `json:"users_updated"`
	PasswordsSet         int  `json:"passwords_set"`
	PasswordsKept        int  `json:"passwords_kept"`
	RootRoleAssignments  int  `json:"root_role_assignments"`
	GroupRoleAssignments int  `json:"group_role_assignments"`
	RemoteApplications   int  `json:"remote_applications"`
	RemoteAppRootRoles   int  `json:"remote_application_root_roles"`
}

type BootstrapManifestUser

type BootstrapManifestUser struct {
	Email         string                 `json:"email" yaml:"email"`
	PhoneNumber   string                 `json:"phone_number" yaml:"phone_number"`
	Username      string                 `json:"username" yaml:"username"`
	EmailVerified bool                   `json:"email_verified" yaml:"email_verified"`
	PhoneVerified bool                   `json:"phone_verified" yaml:"phone_verified"`
	Banned        bool                   `json:"banned" yaml:"banned"`
	BannedAt      *time.Time             `json:"banned_at" yaml:"banned_at"`
	BannedUntil   *time.Time             `json:"banned_until" yaml:"banned_until"`
	BanReason     *string                `json:"ban_reason" yaml:"ban_reason"`
	BannedBy      *string                `json:"banned_by" yaml:"banned_by"`
	Metadata      map[string]any         `json:"metadata" yaml:"metadata"`
	Password      *BootstrapUserPassword `json:"password" yaml:"password"`
	// RootRole assigns one root permission-group role to this user by name.
	// "owner" (the built-in apex, root:*) is seeded SEED-IF-ABSENT; any other
	// name is assigned as a same-named catalog role of the root persona.
	RootRole string `json:"root_role" yaml:"root_role"`
}

type BootstrapReconcileOptions

type BootstrapReconcileOptions struct {
	DryRun bool
	// StartupOnly applies the manifest at most once, using Name as the marker.
	// Leave false for ordinary operator/CLI applies.
	StartupOnly bool
	// Name scopes the startup apply-once marker. Empty means "default".
	Name string
}

type BootstrapUserPassword

type BootstrapUserPassword struct {
	Plaintext     string         `json:"plaintext" yaml:"plaintext"`
	Hash          string         `json:"hash" yaml:"hash"`
	HashAlgo      string         `json:"hash_algo" yaml:"hash_algo"`
	HashParams    map[string]any `json:"hash_params" yaml:"hash_params"`
	ResetRequired bool           `json:"reset_required" yaml:"reset_required"`
	// Enforce makes the password DESIRED-STATE (#89): re-asserted on every
	// reconcile. Default false = SEED-ONCE — the password is applied only when
	// the user is first created, so a password rotated out of band (via the
	// admin API) is never reverted to the manifest value on a later reconcile.
	// Must not be combined with ResetRequired (forcing a reset every run is
	// nonsensical).
	Enforce bool `json:"enforce" yaml:"enforce"`
}

type Client

type Client interface {
	AdminCountUsers(ctx context.Context, opts AdminUserListOptions) (int64, error)
	AdminGetUser(ctx context.Context, id string) (*AdminUser, error)
	AdminListUserSessions(ctx context.Context, userID string) ([]Session, error)
	AdminListUsers(ctx context.Context, opts AdminUserListOptions) (*AdminListUsersResult, error)
	AdminRevokeUserSessions(ctx context.Context, userID string) error
	AdminSetPassword(ctx context.Context, userID, new string) error
	AssignRoleBySlug(ctx context.Context, userID, slug string) error
	AssignGroupRole(ctx context.Context, persona, instanceSlug, subjectID, subjectKind, role string) error
	AssignRoleBySlugAs(ctx context.Context, actorUserID, userID, slug string) error
	RemoveRoleBySlugAs(ctx context.Context, actorUserID, userID, slug string) error
	AssignGroupRoleAs(ctx context.Context, actorUserID, persona, instanceSlug, subjectID, subjectKind, role string) error
	UnassignGroupRoleAs(ctx context.Context, actorUserID, persona, instanceSlug, subjectID, subjectKind, role string) error
	RemoveGroupSubjectAs(ctx context.Context, actorUserID, persona, instanceSlug, subjectID, subjectKind string) error
	ListRoleSlugsByUserErr(ctx context.Context, userID string) ([]string, error)
	CreateGroupInviteLink(ctx context.Context, req CreateGroupInviteLinkRequest) (GroupInviteLinkCreated, error)
	ListGroupInviteLinks(ctx context.Context, persona, instanceSlug string) ([]GroupInviteLink, error)
	RevokeGroupInviteLink(ctx context.Context, persona, instanceSlug, linkID string) error
	RedeemGroupInviteLink(ctx context.Context, code, redeemerUserID string) (RedeemGroupInviteLinkResult, error)
	ExternalInvitesEnabled() bool
	BanUser(ctx context.Context, userID string, reason *string, until *time.Time, bannedBy string) error
	Can(ctx context.Context, subjectID, subjectKind, persona, instanceSlug, perm string) (bool, error)
	ListEffectivePermissions(ctx context.Context, subjectID, subjectKind, persona, instanceSlug string) ([]string, error)
	ChangePassword(ctx context.Context, userID, current, new string, keepSessionID *string) error
	CheckSMSHealth(ctx context.Context) error
	CleanupExpiredAuthState(ctx context.Context) error
	CreatePermissionGroup(ctx context.Context, req CreatePermissionGroupRequest) (string, error)
	CreateUser(ctx context.Context, email, username string) (*User, error)
	DeleteRemoteApplication(ctx context.Context, issuer string) error
	EnsureRootGroup(ctx context.Context) (string, error)
	ExchangeRefreshToken(ctx context.Context, refreshToken string, ua string, ip net.IP) (string, time.Time, string, error)
	GetEmailByUserID(ctx context.Context, id string) (string, error)
	GetProviderUsername(ctx context.Context, userID, provider string) (string, error)
	GetUserMetadata(ctx context.Context, userID string) (map[string]any, error)
	GetRemoteApplication(ctx context.Context, issuer string) (*RemoteApplication, error)
	GetUserByEmail(ctx context.Context, email string) (*User, error)
	GetUserByPhone(ctx context.Context, phone string) (*User, error)
	GetUserBySolanaAddress(ctx context.Context, address string) (*User, error)
	GetUserByUsername(ctx context.Context, username string) (*User, error)
	HardDeleteUser(ctx context.Context, userID string) error
	HasEmailSender() bool
	HasSMSSender() bool
	ImportUsers(ctx context.Context, inputs []ImportUserInput) (ImportUsersResult, error)
	IsUserAllowed(ctx context.Context, userID string) (bool, error)
	IssueAccessToken(ctx context.Context, userID, email string, extra map[string]any) (string, time.Time, error)
	LinkProvider(ctx context.Context, userID, provider, subject string, email *string) error
	LinkProviderByIssuer(ctx context.Context, userID, issuer, providerSlug, subject string, email *string) error
	ListAPIKeys(ctx context.Context, persona, instanceSlug string) ([]APIKey, error)
	ListEntitlements(ctx context.Context, userID string) []string
	ListGroupMembers(ctx context.Context, persona, instanceSlug string) ([]GroupMember, error)
	ListSubjectGroups(ctx context.Context, subjectID, subjectKind string) ([]SubjectGroupMembership, error)
	ListRemoteApplications(ctx context.Context, activeOnly bool) ([]RemoteApplication, error)
	ListRoleSlugsByUser(ctx context.Context, userID string) []string
	ListUserSessions(ctx context.Context, userID string) ([]Session, error)
	ListUsersDeletedBefore(ctx context.Context, cutoff time.Time, limit int) ([]string, error)
	MintAPIKey(ctx context.Context, persona, instanceSlug, name, role, createdBy string, expiresAt *time.Time) (APIKey, string, error)
	MintAPIKeyWithOptions(ctx context.Context, persona, instanceSlug string, opts APIKeyMintOptions) (APIKey, string, error)
	MintCustomJWT(ctx context.Context, opts CustomJWTMintOptions) (string, error)
	MintDelegatedAccessToken(ctx context.Context, p DelegatedAccessParams) (string, error)
	MintRemoteApplicationAccessToken(ctx context.Context, p RemoteApplicationAccessParams) (string, error)
	MintServiceJWT(ctx context.Context, opts ServiceJWTMintOptions) (string, ServiceJWTClaims, error)
	PatchUserMetadata(ctx context.Context, userID string, patch map[string]any) error
	StartPasswordless(ctx context.Context, req PasswordlessStartRequest) (PasswordlessStartResult, error)
	ConfirmPasswordlessCode(ctx context.Context, identifier, code string) (PasswordlessConfirmResult, error)
	ConfirmPasswordlessToken(ctx context.Context, token string) (PasswordlessConfirmResult, error)
	RecordFailedPasswordlessCode(ctx context.Context, identifier string)
	ClearPasswordlessCodeAttempts(ctx context.Context, identifier string)
	// ApplyBootstrapManifest applies a parsed manifest. There is deliberately no
	// ApplyBootstrapManifestFile on the contract: a file path is the SERVER's
	// filesystem, meaningless over a remote transport (#142). Hosts with a file
	// load it themselves (e.g. embedded.LoadBootstrapManifestFile) then call this.
	ApplyBootstrapManifest(ctx context.Context, manifest BootstrapManifest, opts BootstrapReconcileOptions) (BootstrapManifestResult, error)
	RemoveRoleBySlug(ctx context.Context, userID, slug string) error
	ResolveAPIKey(ctx context.Context, keyID, secret string) (string, []string, error)
	ResolveAPIKeyWithResources(ctx context.Context, keyID, secret string) (ResolvedAPIKey, error)
	ResolveGroupIDForSlug(ctx context.Context, persona, instanceSlug string) (string, error)
	ResolveRemoteAppAttributeDef(ctx context.Context, appID, key string, version int32) (*RemoteAppAttributeDef, error)
	ResolveRemoteApplicationAuthority(ctx context.Context, appID string) ([]string, error)
	RestoreUser(ctx context.Context, id string) error
	RevokeAPIKey(ctx context.Context, persona, instanceSlug, tokenID string) (bool, error)
	RevokeAllSessions(ctx context.Context, userID string, keepSessionID *string) error
	SMSAvailable() bool
	SeedPermissionGroupContainment(ctx context.Context) error
	SetEmailVerified(ctx context.Context, id string, v bool) error
	SoftDeleteUser(ctx context.Context, id string) error
	TimeUntilUsernameRenameAvailable(ctx context.Context, userID string, now time.Time) (int64, error)
	UnbanUser(ctx context.Context, userID string) error
	UnlinkProvider(ctx context.Context, userID, provider string) error
	UpdateBiography(ctx context.Context, id string, bio *string) error
	UpdateEmail(ctx context.Context, id, email string) error
	UpdateImportedUser(ctx context.Context, userID string, input ImportUserInput) (*User, error)
	UpdateUsername(ctx context.Context, id, username string) error
	UpsertPasswordHash(ctx context.Context, userID, hash, algo string, params []byte) error
	UpsertRemoteApplication(ctx context.Context, in RemoteApplication) (*RemoteApplication, error)
	UpsertRoleBySlug(ctx context.Context, name, slug string, description *string) error
	ValidateVerificationConfiguration() error
	VerifyUserPassword(ctx context.Context, userID, pass string) bool

	// UsersByIDs resolves many user IDs to slim display projections (id +
	// username/email) in ONE query — the batch read for "render N authors"
	// without N+1. Missing IDs are simply absent from the result. (Replaces the
	// removed authkit/identity store; writes go through UpdateUsername/UpdateEmail,
	// which enforce the rename cooldown + validation raw table writes skip.)
	UsersByIDs(ctx context.Context, ids []string) ([]UserRef, error)
}

Client is the portable AuthKit contract — the curated subset of operations meaningful across both the in-process (embedded) and the Phase-2 remote transports (issue #138). Infra accessors (Postgres, Keyfunc, JWKS, raw Options/Schema) are deliberately OFF this interface; they stay on the concrete *embedded.Client. Code against authkit.Client so swapping backends is construction-only:

var c authkit.Client = embedded.New(cfg, pg)     // today (in-process)
// var c authkit.Client = remote.New(url, creds) // Phase 2 (standalone)

type CreateGroupInviteLinkRequest

type CreateGroupInviteLinkRequest struct {
	Persona      string
	InstanceSlug string
	Role         string
	Email        string
	MaxUses      *int
	ExpiresIn    time.Duration
	InvitedBy    string
}

type CreatePermissionGroupRequest

type CreatePermissionGroupRequest struct {
	Persona            string
	InstanceSlug       string
	ParentPersona      string
	ParentInstanceSlug string
	OwnerSubjectID     string
}

type CustomJWTMintOptions

type CustomJWTMintOptions struct {
	// Claims is the host's claim set, e.g. {"cap_kind": "...", "grants": [...],
	// "release_id": "..."}. Required and non-empty. It may carry `sub`/`aud`
	// (unless overridden by the Subject/Audiences options) but may NOT carry the
	// AuthKit-owned registered claims `iss`/`iat`/`exp`.
	Claims map[string]any
	// TTL is the token lifetime. Required (must be > 0); capped at
	// MaxCustomJWTLifetime.
	TTL time.Duration
	// Type is the JOSE `typ` header (e.g. "worker-capability+jwt"). When empty the
	// header is left unset — unlike the opinionated minters, MintCustomJWT does
	// not impose a default `typ`; the host owns the token shape. It may NOT be one
	// of AuthKit's own first-party classes (access / delegated-access /
	// remote-application-access / service `+jwt`) — doing so returns
	// ErrCustomJWTReservedType (AK2-AUTH-02).
	Type string
	// Subject, when set, becomes the `sub` claim and wins over any `sub` in Claims.
	Subject string
	// Audiences, when set, becomes the `aud` claim and wins over any `aud` in Claims.
	Audiences []string
	// Issuer, when set, becomes the `iss` claim; otherwise `iss` defaults to the
	// Service's configured Issuer. This is the ONLY way to override `iss`.
	Issuer string
}

type DelegatedAccessParams

type DelegatedAccessParams struct {
	// Issuer becomes the `iss` claim: the AuthKit issuer that signed the token.
	// Must match a remote_application registered with the validating resource server.
	// Required when minting via the free function; the *Service mint method
	// defaults it to the Service's configured Issuer when empty.
	Issuer string
	// Audiences becomes the `aud` claim: the target resource API(s), e.g.
	// "openrails", "tensorhub", or "gen-orchestrator".
	Audiences []string
	// DelegatedSubject becomes `delegated_sub`: the issuer-side subject id.
	// Required. No local account is implied in the receiving service.
	DelegatedSubject string
	// Permissions becomes the `permissions` claim: an array of resource-defined
	// permission strings (NOT OAuth's space-delimited `scope`). Receiving
	// services validate these against their own permission set.
	Permissions []string
	// Attributes becomes the `attributes` claim: the canonical app-specific
	// ESCAPE HATCH (#75). An object of issuer-asserted, NAMESPACED, OPAQUE
	// key/values that AuthKit transports + optionally shape-validates but NEVER
	// interprets — the semantics belong to the consuming app (tensorhub etc.).
	// Each value is set in ONE of two modes, per key:
	//   INLINE    — the value carries the full definition, e.g.
	//               {"tier":{"endpoints":[...],"caps":[...]}}. No lookup.
	//   REFERENCE — the value is a short string key, e.g. {"tier":"tier-1"},
	//               resolved by the consumer against a definition the
	//               remote_application registered ahead of time (see the
	//               attribute-def registry: Service.RegisterRemoteAppAttributeDef
	//               / ResolveRemoteAppAttributeDef). Keeps tokens small.
	// Reserved well-known keys: `tier` (opaque entitlement-tier string) and
	// `roles` (a uuid array; prefer the typed Roles field below). Everything
	// else is free-form per consuming app. Values are arbitrary JSON.
	Attributes map[string]any
	// Roles is a convenience for emitting the delegated subject's role UUIDs into
	// `attributes.roles` (a JSON array of UUID strings). Equivalent to setting
	// Attributes["roles"] yourself; when both are set this typed field wins.
	Roles []string
	// TTL is the token lifetime. Defaults to 15m when zero.
	TTL time.Duration
	// JTI, when set, becomes the `jti` claim (token identifier). Optional.
	JTI string
	// NotBefore, when set, becomes the `nbf` claim. Optional.
	NotBefore time.Time
}

type ErrorEnvelope

type ErrorEnvelope struct {
	Error ErrorObject `json:"error"`
}

ErrorEnvelope is the top-level error response: {"error": {...}}.

func NewErrorEnvelope

func NewErrorEnvelope(status int, code string, param *string, metadata map[string]any) ErrorEnvelope

NewErrorEnvelope builds the canonical nested error envelope for an HTTP status + machine code: the type is derived from the status and the message from the code catalog. param and metadata are optional (omitted when nil/empty).

type ErrorObject

type ErrorObject struct {
	Type     string         `json:"type"`
	Code     string         `json:"code"`
	Message  string         `json:"message"`
	Param    *string        `json:"param,omitempty"`
	Metadata map[string]any `json:"metadata,omitempty"`
}

ErrorObject is the nested error detail carried under the top-level "error" key.

type GroupInviteLink struct {
	ID                string
	PermissionGroupID string
	Role              string
	InvitedBy         string
	Email             string // "" = shareable (anyone may redeem)
	MaxUses           *int   // nil = unlimited
	Uses              int
	ExpiresAt         *time.Time
	RevokedAt         *time.Time
	CreatedAt         time.Time
	UpdatedAt         time.Time
}

type GroupInviteLinkCreated

type GroupInviteLinkCreated struct {
	ID   string
	Code string
	URL  string
}

type GroupMember

type GroupMember struct {
	SubjectID   string
	SubjectKind string
	Role        string
}

type ImportUserInput

type ImportUserInput struct {
	Email         string
	PhoneNumber   string
	Username      string
	EmailVerified bool
	PhoneVerified bool
	BannedAt      *time.Time
	BannedUntil   *time.Time
	BanReason     *string
	BannedBy      *string
	Metadata      map[string]any
	CreatedAt     *time.Time
	UpdatedAt     *time.Time

	// Optional pre-hashed credential to import alongside the user (bulk legacy
	// migration). When PasswordHash is non-empty and the user row is inserted,
	// ImportUsers stores it verbatim. The verify-time whitelist (argon2id/bcrypt,
	// else legacy-reset-required) still governs login; bulk import does not
	// re-validate the hash, matching single-row UpsertPasswordHash.
	PasswordHash string
	HashAlgo     string
	HashParams   []byte
}

type ImportUserResult

type ImportUserResult struct {
	Index  int
	UserID string // set when Status == inserted
	Status ImportUserStatus
	Reason string // set for skipped/rejected (machine-ish: "duplicate_in_batch", "already_exists", or a validation code)
}

type ImportUserStatus

type ImportUserStatus string

type ImportUsersResult

type ImportUsersResult struct {
	Results  []ImportUserResult
	Inserted int
	Skipped  int
	Rejected int
}

type MFAStatus

type MFAStatus struct {
	Enabled        bool
	Satisfied      bool
	AllowedMethods []string
}

type PasswordlessConfirmResult

type PasswordlessConfirmResult struct {
	UserID   string
	Method   string
	ReturnTo string
}

type PasswordlessStartRequest

type PasswordlessStartRequest struct {
	Identifier        string
	Mode              string
	ReturnTo          string
	PreferredLanguage string
}

type PasswordlessStartResult

type PasswordlessStartResult struct {
	Sent    bool
	Channel string
	Code    string
	LinkURL string
}

type PreferredLanguage

type PreferredLanguage struct {
	Language string
}

type RedeemGroupInviteLinkResult

type RedeemGroupInviteLinkResult struct {
	Persona      string
	InstanceSlug string
	Role         string
}

type RemoteAppAttributeDef

type RemoteAppAttributeDef struct {
	RemoteApplicationID string
	Key                 string
	Version             int32
	Definition          json.RawMessage
}

RemoteAppAttributeDef is a remote_application's registered attribute definition: the full inline value a REFERENCE-mode delegated-token attribute resolves to (#75). Definition is opaque JSON the consuming app interprets.

type RemoteAppKey

type RemoteAppKey struct {
	KID          string `json:"kid,omitempty" yaml:"kid,omitempty"`
	PublicKeyPEM string `json:"public_key_pem" yaml:"public_key_pem"`
}

RemoteAppKey is one entry of a static-mode principal's human-managed key list (stored as jsonb; edited like an authorized_keys file).

type RemoteApplication

type RemoteApplication struct {
	ID                string
	Slug              string
	PermissionGroupID string // controlling permission-group id
	Issuer            string // OIDC iss
	JWKSURI           string // OIDC jwks_uri (jwks mode only)
	// Mode is the trust source: RemoteAppModeJWKS (fetch from JWKSURI) XOR
	// RemoteAppModeStatic (human-managed PublicKeys list). Never both.
	Mode string
	// PublicKeys is the static-mode key list (empty in jwks mode).
	PublicKeys     []RemoteAppKey
	AllowedOrigins []string
	Enabled        bool
	CreatedAt      time.Time
	UpdatedAt      time.Time
}

RemoteApplication is a registered federation principal: an external issuer authkit trusts to mint delegated/remote-application tokens. It is a plain data view; persistence and lifecycle live in core.

type RemoteApplicationAccessParams

type RemoteApplicationAccessParams struct {
	// Issuer becomes the `iss` claim: the remote_application's OIDC issuer,
	// registered with the validating resource server. Required when minting via
	// the free function; the *Service mint method defaults it to the Service's
	// configured Issuer when empty.
	Issuer string
	// Audiences becomes the `aud` claim: the target resource API(s).
	Audiences []string
	// TTL is the token lifetime. Defaults to 15m when zero.
	TTL time.Duration
	// JTI, when set, becomes the `jti` claim. Optional.
	JTI string
	// NotBefore, when set, becomes the `nbf` claim. Optional.
	NotBefore time.Time
	// Permissions, when non-nil, becomes the `permissions` claim: a DOWN-SCOPING
	// request for least-privilege (#76 amendment). The stored grant is the
	// ceiling; effective = this claim, but EVERY claimed perm must be within the
	// stored grant — an out-of-grant claimed perm REJECTS the token at verify (a
	// remote application access token can never widen). nil/absent => no claim
	// => full stored ceiling (backward-compatible with v0.28.0 tokens).
	Permissions []string
}

type ResolvedAPIKey

type ResolvedAPIKey struct {
	APIKeyID string
	KeyID    string
	// PermissionGroupID is the controlling permission-group id.
	PermissionGroupID string
	Role              string
	Permissions       []string
	Resources         []APIKeyResource
}

ResolvedAPIKey is the resource-aware API-key resolution result. Permissions is the key's role resolved to its effective permission set AT VERIFY TIME (so a role edit is reflected immediately — perms are never frozen into the key).

type ServiceJWTClaims

type ServiceJWTClaims struct {
	Issuer      string
	Subject     string
	Audiences   []string
	IssuedAt    time.Time
	NotBefore   time.Time
	ExpiresAt   time.Time
	JTI         string
	TokenUse    string
	Permissions []string
	Resources   []APIKeyResource
	Scope       []string
}

ServiceJWTClaims is the canonical AuthKit claim shape for caller-minted machine-to-machine JWTs. Permissions are requested capabilities; receiving services must still intersect them with server-side grants.

type ServiceJWTMintOptions

type ServiceJWTMintOptions struct {
	Subject     string
	Audiences   []string
	Permissions []string
	Resources   []APIKeyResource
	Lifetime    time.Duration
	NotBefore   time.Time
	IssuedAt    time.Time
	JTI         string
}

type Session

type Session struct {
	ID                  string
	FamilyID            string
	CreatedAt           time.Time
	LastAuthenticatedAt *time.Time
	LastUsedAt          time.Time
	ExpiresAt           *time.Time
	RevokedAt           *time.Time
	UserAgent           *string
	IPAddr              *string
}

Session is a sanitized session view (no tokens). Part of the wire contract.

type SubjectGroupMembership

type SubjectGroupMembership struct {
	Persona      string
	InstanceSlug string
	Role         string
}

type TokenIssuer added in v0.67.0

type TokenIssuer interface {
	IssueAccessToken(ctx context.Context, userID, email string, extra map[string]any) (string, time.Time, error)
	MintCustomJWT(ctx context.Context, opts CustomJWTMintOptions) (string, error)
	MintDelegatedAccessToken(ctx context.Context, p DelegatedAccessParams) (string, error)
	MintRemoteApplicationAccessToken(ctx context.Context, p RemoteApplicationAccessParams) (string, error)
	MintServiceJWT(ctx context.Context, opts ServiceJWTMintOptions) (string, ServiceJWTClaims, error)
}

TokenIssuer is the token-minting slice: service JWTs, delegated access tokens, custom and remote-application tokens. openrails/tensorhub platform minting depends on this.

type User

type User struct {
	ID              string
	Email           *string // Nullable - phone-only users have NULL email
	PhoneNumber     *string
	Username        *string
	DiscordUsername *string
	EmailVerified   bool
	PhoneVerified   bool
	BannedAt        *time.Time
	BannedUntil     *time.Time
	BanReason       *string
	BannedBy        *string
	DeletedAt       *time.Time
	Biography       *string
	CreatedAt       time.Time
	UpdatedAt       time.Time
	LastLogin       *time.Time
}

User is the public user view returned by AuthKit lookups. Plain data — part of the wire contract shared by the embedded engine and (Phase 2) the remote SDK. See #138 (contract inversion): definitions live here in the lean, pgx-free contract package; internal/authcore aliases back to these.

type UserRef added in v0.66.0

type UserRef struct {
	ID       string
	Username string // "" if unset
	Email    string // "" if unset
}

UserRef is a slim user projection (id + display fields) returned by batch lookups like Client.UsersByIDs — resolving many user IDs to display data in one query, without N+1 single fetches. Part of the wire contract.

Directories

Path Synopsis
adapters
chi
gin
Package testing provides utilities for testing applications that use authkit.
Package testing provides utilities for testing applications that use authkit.
cmd
Re-exports of the public types, constants, sentinel errors, and helper functions implemented in internal/authcore.
Re-exports of the public types, constants, sentinel errors, and helper functions implemented in internal/authcore.
internal
db
Schema indirection (authkit issue 69).
Schema indirection (authkit issue 69).
migrations
postgres
Package migrations embeds AuthKit's Postgres schema migrations.
Package migrations embeds AuthKit's Postgres schema migrations.
Package remote is the AuthKit remote SDK: a client that talks to a standalone AuthKit server's management API over HTTP, satisfying the same AuthKit capability interfaces an in-process embedded.Client does (#142).
Package remote is the AuthKit remote SDK: a client that talks to a standalone AuthKit server's management API over HTTP, satisfying the same AuthKit capability interfaces an in-process embedded.Client does (#142).
Package server hosts the AuthKit management HTTP API — the wire contract that a standalone AuthKit server exposes and the authkit/remote SDK consumes (#142).
Package server hosts the AuthKit management HTTP API — the wire contract that a standalone AuthKit server exposes and the authkit/remote SDK consumes (#142).
Package siws implements Sign In With Solana (SIWS) authentication.
Package siws implements Sign In With Solana (SIWS) authentication.
storage

Jump to

Keyboard shortcuts

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