sqlite-scale micrOAuth identity provider
  • Go 92.9%
  • CSS 3.1%
  • HTML 3%
  • Makefile 0.9%
  • Dockerfile 0.1%
Find a file
Coco (Agent) 62a57065ec
fixed: filled the screen with the mobile menu on iPhone
On iOS Safari the slide-over menu stopped above the floating toolbar, because
its height was '100dvh'. It now stretches to the top and bottom edges. Its
padding grows by the safe-area insets and the toolbar's height, so its links
and the account row stay clear of the status bar, notch and toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:41:49 -04:00
.claude/skills added: documented the issuer and single-audience token model 2026-10-20 19:34:59 -04:00
cmd changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
internal fixed: filled the screen with the mobile menu on iPhone 2026-10-26 17:41:49 -04:00
pkg changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
.dockerignore added: docker infrastructure 2026-05-10 23:49:29 -04:00
.env.example added: debug logging of authorization events 2026-10-24 18:41:32 -04:00
.gitignore added: docker compose deployment 2026-10-21 15:50:56 -04:00
AGENTS.md changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
CHANGELOG.md added: v0.7.0 release notes 2026-10-26 15:38:44 -04:00
compose.yaml fixed: made the compose deployment build and start reliably 2026-10-21 17:50:40 -04:00
Dockerfile changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
go.mod changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
go.sum changed: moved to Go 1.27 and decoded untrusted JSON strictly 2026-10-26 03:27:04 -04:00
LICENSE added: AGPL license 2025-04-05 12:17:16 -04:00
Makefile added: a command that creates the first administrator 2026-10-24 18:41:26 -04:00
README.md added: a public protocol package and per-integration client versions 2026-10-25 16:06:18 -04:00

Consent: Simplified OAuth for Server Applications

Consent is a streamlined authentication service that distills OAuth 2.0's authorization code flow into a server-focused solution. By simplifying OAuth's per-client secret management while maintaining cryptographic security, Consent provides secure authentication specifically tailored for backend server applications that only target a browser-based front-end.

Core Architecture

The system consists of three main components: an authentication server, a client library for backend integration, and persistent storage. The authentication server hosts both a web interface for user login and a RESTful API for token operations. Core server runtime configuration lives under a config directory with a generated config.yaml, file-backed secrets, and operator environment metadata managed through the CLI. Mutable runtime state such as SQLite data lives under a separate data directory. Service registrations themselves are API-managed records stored in SQLite.

The client library provides server-side functionality for backend applications, including automatic authorization code handling, token validation with automatic refresh, and built-in CSRF protection. This library runs entirely on the application backend—browsers never see cryptographic operations, only cookies and redirects.

Data persistence uses SQLite tables for identity, durable per-user per-service grant records, and active refresh tokens. This keeps Consent small while separating Consent login from third-party authorization.

Authentication Flow

The authorization process mirrors OAuth's security model while simplifying implementation. When users access a protected service, they're redirected to Consent's /authorize endpoint with a service identifier and one or more scopes. Consent first ensures the user has its own Consent session, then reuses or records durable grants before issuing a short-lived refresh token (10 seconds) as an authorization code.

The user is then redirected back to the service with this code, which the client application backend automatically exchanges for long-lived access and refresh tokens through the /api/v1/auth/refresh endpoint. This maintains OAuth's security benefits—the authorization code prevents long-lived token exposure in browser history—while streamlining the developer experience.

Key Design Decisions

Simplified Secret Management: Unlike OAuth's per-client secrets, Consent uses a single ECDSA key pair distributed to all client backend servers that integrate with a particular Consent instance. The auth server holds the private signing key while client backends share the public verification key. This eliminates per-client secret management while maintaining cryptographic security through server-to-server communication. A primary intended use case that this supports is where a sysadmin deploys multiple consent-enabled services on the same node, making key sharing between clients simple through symbolic links.

Integrated CSRF Protection: Refresh tokens include cryptographic secrets that serve double duty as CSRF tokens, providing protection against cross-site request forgery without additional infrastructure.

Token Rotation: Refresh tokens are single-use and replaced on every refresh operation, limiting the damage from token compromise while maintaining session continuity.

Backend-Only Cryptography: All token operations happen server-side. Browsers interact only through secure cookies and redirects, never seeing cryptographic keys or performing validation logic.

Operational Benefits

Simplified Deployment: Client applications do not need their own OAuth client secret. Deploy the Consent verification key to each backend and register each app as an integration so Consent can validate its audience, redirect URL, homepage, and logo, and know which capabilities it understands.

Easier Key Management: Single key pair per Consent instance instead of managing individual client secrets. Key rotation affects all clients uniformly.

Reduced Implementation Complexity: The client library handles all token lifecycle management automatically. Applications simply use verification functions to protect routes without implementing OAuth flows manually.

Security Model

Consent maintains OAuth's proven security approach:

  • Third-party services never receive user credentials directly
  • Tokens have limited lifetimes with automatic refresh
  • ECDSA signatures prevent token tampering
  • Authorization codes are short-lived (10 seconds) to minimize exposure window
  • HttpOnly, Secure, SameSite cookies prevent XSS-based token theft

The server-to-server architecture ensures that cryptographic operations remain secure while eliminating the complexity that often leads to implementation vulnerabilities in OAuth deployments.

Use Cases

Consent is ideal for:

  • Multiple backend services needing shared authentication
  • Microservice architectures requiring lightweight auth
  • Organizations wanting OAuth-level security without OAuth complexity
  • Applications where simplified key distribution outweighs per-client secret isolation

In sum, Consent is designed to provide easy "login with facebook" style authentication for small, open source, community-scale software projects.

This approach reduces OAuth implementation time from weeks to hours while maintaining the security guarantees that make OAuth suitable for production authentication systems.

Package Overview

The pkg/ directory contains public packages for consuming projects:

  • pkg/client: Client library for backend applications integrating with a consent server. Provides the Verifier interface for protecting routes, automatic token refresh, and CSRF protection. It re-exports the protocol types an app handles, such as client.UserInfo and client.IntegrationManifest, so apps import only this package and pkg/tokens.
  • pkg/protocol: The contract between Consent and its integrations: the protocol version, the endpoints apps call and their request and response bodies, and the integration manifest. Consent and pkg/client both build on it, and it imports only the standard library.
  • pkg/tokens: JWT token utilities including InitClient for creating token validators with ECDSA public keys.
  • pkg/testing: Test utilities for consuming projects. Provides TestVerifier (implements client.Verifier) for testing authenticated routes without a real consent server, plus dev login handlers for local browser-based development.

Future work: scope definitions live in pkg/tokens beside token encoding. They are protocol vocabulary and could move to pkg/protocol.

The cmd/ directory also includes development-focused binaries:

  • cmd/dev-client: A local integration playground for testing how a service integrates with consent. This command always enables client development mode and is not intended for real world usage.

Integration Guide

Consent client integration has two halves:

  1. The consuming backend mounts Consent auth handlers and validates Consent-issued cookies with the shared verification key.
  2. The Consent operator registers that backend as an integration from the manifest it publishes at /.well-known/consent-integration, using the admin UI or consent api integrations apply.

The integration record is not an OAuth client secret. It is public metadata that tells Consent which app is requesting authorization, which JWT audience to issue, where authorization codes may be redirected, where users can launch the app, which logo to show, and which capabilities the app's code knows how to authorize. Consent groups decide who may use the app and which of those capabilities each user receives.

Issuers and Audiences

Every token names its signer and its recipient:

  • Issuer (iss): the Consent deployment's authority domain.
  • Audience (aud): a single value, the audience of the integration the token was issued to. An app's tokens carry that app's audience. Consent's own web UI is the built-in consent integration, so its tokens carry the host of Consent's public URL.

A backend accepts a token when the issuer matches, the signature verifies, and the audience equals its own. Consent's API accepts its own tokens by issuer and signature, then uses the audience to find the integration and apply its rules.

Production Integration

import (
	"crypto/ecdsa"
	"crypto/x509"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"

	"git.sr.ht/~jakintosh/consent/pkg/client"
	"git.sr.ht/~jakintosh/consent/pkg/tokens"
)

const (
	consentURL      = "https://consent.example.com"
	consentIssuer   = "consent.example.com"
	publicURL       = "https://myapp.example.com"
	integrationName = "myapp"
)

var requestedScopes = []string{tokens.ScopeIdentity, tokens.ScopeProfile, tokens.ScopeGroups}

// capabilities are the actions this app's code checks. Consent admins decide
// which groups receive each one.
var capabilities = []client.IntegrationCapability{
	{Name: "invoices.read", Display: "Read invoices"},
	{Name: "invoices.approve", Display: "Approve invoices", Description: "Approve invoices for payment."},
}

func main() {
	authClient, loginURL, manifestHandler, err := buildAuth()
	if err != nil {
		log.Fatal(err)
	}

	router := http.NewServeMux()
	router.HandleFunc("/auth/callback", authClient.HandleAuthorizationCode())
	router.HandleFunc("/auth/logout", authClient.HandleLogout())
	router.HandleFunc(client.IntegrationManifestPath, manifestHandler)

	router.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
		accessToken, err := authClient.VerifyAuthorization(w, r)
		if err != nil {
			http.Redirect(w, r, loginURL, http.StatusSeeOther)
			return
		}

		// Use accessToken.Subject() as the stable opaque Consent user key.
		// Do not key local data by handle; handles can change.
		_ = accessToken.Subject()
	})
}

func buildAuth() (*client.Client, string, http.HandlerFunc, error) {
	publicKey, err := loadVerificationKey("./config/verification_key")
	if err != nil {
		return nil, "", nil, err
	}

	appURL, err := url.Parse(publicURL)
	if err != nil {
		return nil, "", nil, err
	}
	audience := appURL.Host

	validator := tokens.InitClient(tokens.ClientOptions{
		VerificationKey: publicKey,
		IssuerDomain:    consentIssuer,
		ValidAudience:   audience,
	})
	authClient, err := client.Init(validator, consentURL)
	if err != nil {
		return nil, "", nil, err
	}

	loginURL := buildAuthorizeURL(consentURL, integrationName, requestedScopes)
	manifestHandler := client.HandleIntegrationManifest(client.IntegrationManifest{
		Name:           integrationName,
		Display:        "My App",
		Audience:       audience,
		Redirect:       publicURL + "/auth/callback",
		Homepage:       publicURL,
		Logo:           publicURL + "/static/consent-logo.png",
		Capabilities:   capabilities,
		ConsentIssuer:  consentIssuer,
		ConsentBaseURL: consentURL,
	})
	return authClient, loginURL, manifestHandler, nil
}

func loadVerificationKey(path string) (*ecdsa.PublicKey, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	key, err := x509.ParsePKIXPublicKey(data)
	if err != nil {
		return nil, err
	}
	ecdsaKey, ok := key.(*ecdsa.PublicKey)
	if !ok {
		return nil, fmt.Errorf("verification key is not ECDSA")
	}
	return ecdsaKey, nil
}

func buildAuthorizeURL(consentURL, integration string, scopes []string) string {
	u, _ := url.Parse(strings.TrimRight(consentURL, "/") + "/authorize")
	q := u.Query()
	q.Set("integration", integration)
	for _, scope := range scopes {
		q.Add("scope", scope)
	}
	u.RawQuery = q.Encode()
	return u.String()
}

The client logs only errors by default. When sign-in returns to the app without a session, authClient.SetLogLevel(client.LogLevelDebug) logs why the callback or a token check failed.

For state-changing routes, read the CSRF secret when rendering a page, then require it when processing the POST:

accessToken, csrf, err := authClient.VerifyAuthorizationGetCSRF(w, r)
// render csrf into a hidden form field or same-site request URL

accessToken, newCSRF, err := authClient.VerifyAuthorizationCheckCSRF(w, r, r.FormValue("csrf"))
// if the access token was refreshed, newCSRF may differ from the submitted value

If the app needs display/user profile data, request the profile scope along with identity and call Consent's userinfo endpoint through the client:

userInfo, err := authClient.FetchUserInfo(r.Context(), accessToken.Encoded())
if err != nil {
	// Treat this as account setup or profile refresh failure.
	return
}

subject := userInfo.Sub
handle := userInfo.Profile.Handle

Compass follows this pattern in production: it uses Consent's sub as the durable foreign key in its own account table, caches the Consent handle for URLs like /alice/, refreshes that profile data periodically from /api/v1/auth/userinfo, and treats missing profile permission as an account setup error.

Available scopes are below. pkg/tokens exports their names as tokens.ScopeIdentity, tokens.ScopeProfile, and tokens.ScopeGroups, and tokens.ScopeNames() lists them all.

  • identity: required for every authorization request; exposes the stable sub claim and permits use of the token as a user identity.
  • profile: requires identity; exposes the user's Consent handle from /api/v1/auth/userinfo.
  • groups: requires identity; lets the app read, from /api/v1/auth/userinfo, the user's groups that grant access to it.

Capabilities need no scope. They are the app's own vocabulary, and every access token for the app carries the ones the user holds.

Groups and Capabilities

One Consent deployment serves one organization. Its admins sort users into groups, and a group grants its members access to integrations:

  • A user may authorize, refresh, and read user info for an integration only while at least one of their groups grants it. An integration that no group grants is closed to everyone.
  • Each grant selects zero or more of the integration's capabilities. A grant with no capabilities still admits the group's members.
  • A user's capabilities for an integration combine the selections of every group that grants it.
  • Members of the protected admin group administer Consent. consent init creates the group, it cannot be deleted, and its last member cannot be removed. Like any group, it can also grant integrations.

Applications define their capabilities; Consent admins decide who receives them. Check capabilities, not group names, when authorizing an action:

if accessToken.HasCapability("invoices.approve") {
	// Allow approval using the token snapshot.
}

For a security-sensitive action that needs current state, call user info. Consent returns Cache-Control: no-store, and the client verifies that the response subject matches the supplied token. User info always returns current capabilities, and it fails once the user no longer has access to the app:

userInfo, err := authClient.FetchUserInfo(r.Context(), accessToken.Encoded())
if err != nil {
	return
}
if !userInfo.HasCapability("invoices.approve") {
	http.Error(w, "Forbidden", http.StatusForbidden)
	return
}

With the groups scope, user info also lists the user's groups that grant the app, by name and display name, for presentation such as a "Treasurer" badge.

Applications should not store capabilities as permanent local flags. The access token is a bounded snapshot; /userinfo is live. Refresh tokens carry no capabilities, so every refresh re-evaluates the user's current groups.

Keep a capability's meaning stable. If its meaning changes, declare a new name instead of reusing the old one. Consent does not remember removed capabilities, so a name that disappears and later returns is selected by no group.

Access Token Size and Capability Catalogs

Access tokens travel in a browser cookie, and browsers drop cookies larger than about 4 KB. Consent therefore never issues an access token larger than tokens.MaxEncodedAccessTokenSize (3500 bytes).

A token carries only the capabilities of its own integration, and a user never holds more of them than the integration declares. So the largest possible token for an integration carries its whole catalog. When Consent previews or applies a manifest, it encodes that worst-case token, with the deployment's issuer, the integration's audience, every scope, and a full-length subject, and rejects the manifest if the token would be too large. The error states how many bytes over the limit it is. Because the check happens at registration, refresh never fails later for a user whose groups grant many capabilities.

In practice an integration can declare roughly 123 capabilities of 16 characters, 87 of 24 characters, or 67 of 32 characters. The exact number depends on the length of the issuer and audience.

Two guidelines keep catalogs well inside that budget:

  • Keep names short. Use a noun.verb form such as invoices.approve. Put the explanation in the capability's display name and description, which never enter the token.
  • Name kinds of action, not individual records. A capability says what a user may do, such as invoices.approve, not which record they may do it to. Decide per-record rules in the app. If a catalog approaches the limit, its capabilities are probably too fine-grained.

Apps can check their catalog in their own tests before deploying:

func TestCapabilitiesFitInAccessTokens(t *testing.T) {
	err := client.CheckCapabilityBudget(myManifest, "consent.example.com")
	if err != nil {
		t.Fatal(err)
	}
}

Registering a Client App

The app must be registered with Consent before users can authorize it. Consent registers and updates an integration only from its manifest. From the admin UI, sign in as an admin, go to Integrations, choose Add integration, and enter the app root URL. Consent fetches:

https://myapp.example.com/.well-known/consent-integration

When Consent cannot reach the app, paste the manifest JSON instead. Consent reads manifests strictly. Unknown fields and unknown manifest versions are rejected. The redirect and homepage hosts must match the audience, and every capability needs a unique name and a display name.

A manifest also declares the app's Consent client library: its module version in client_version and the protocol it speaks in protocol. The protocol covers the token format and API that the library and server share, and changes whenever both must be upgraded together. Consent registers an app only when its protocol matches the server's, and otherwise says which side to upgrade. client.HandleIntegrationManifest fills in these fields.

Consent records the client version and protocol with each registration and shows them on the integration's page. When an upgrade changes the server's protocol, apps registered with the old one can no longer sign people in. The Integrations page marks them as needing an upgrade, and consent init lists them on every start. Upgrade the app's library, then refresh it from its manifest.

Consent always shows a review before applying a manifest. The review lists changed fields and added, changed, and removed capabilities. For each removed capability it names the groups that grant it and the users who will lose it. Applying the manifest deletes removed capabilities from every group, and new capabilities start out granted to no group. An integration's name and audience cannot change; register a new integration instead. To pick up a changed manifest later, open the integration and refresh it the same way.

The same flow is available from the CLI. preview prints the review, including the base_digest of the registration it compared against. apply takes that digest and fails if the registration changed in the meantime. Omit it when registering a new integration.

curl -fsS https://myapp.example.com/.well-known/consent-integration > myapp.json
consent api integrations preview myapp.json --config-dir ./config
consent api integrations apply myapp.json --config-dir ./config

Then grant the app to groups. In the admin UI, open a group under Groups, choose Add app, and pick the capabilities its members get. From the CLI, integrations set replaces the complete set of capabilities a group grants; omitting --capability grants access without capabilities.

consent api groups create treasurers \
  --display "Treasurers" \
  --config-dir ./config

consent api groups members add treasurers USER_SUBJECT \
  --config-dir ./config

consent api groups integrations set treasurers myapp \
  --capability invoices.read \
  --capability invoices.approve \
  --config-dir ./config

The consuming app should receive the Consent verification key out of band, typically by mounting or copying Consent's generated verification_key.der from the server config secrets directory. The app can name the mounted file however it wants; Compass, for example, expects verification_key in its own config directory.

Testing Integration

import (
    "git.sr.ht/~jakintosh/consent/pkg/testing"
)

func TestProtectedRoute(t *testing.T) {
    // TestVerifier implements client.Verifier - no network required
    tv := testing.NewTestVerifier("consent.example.com", "my-app")

    router := myapp.NewRouter(tv)  // Inject as Verifier interface

    req, _ := tv.AuthenticatedRequest("GET", "/api/profile", testing.DefaultTestSubject)
    rr := httptest.NewRecorder()
    router.ServeHTTP(rr, req)

    if rr.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rr.Code)
    }
}

Development Mode

For local browser-based development without running a consent server:

tv := testing.NewTestVerifier("consent.example.com", "my-app")

http.HandleFunc("/dev/login", tv.HandleDevLogin())
http.HandleFunc("/dev/logout", tv.HandleDevLogout())

Local Integration Workflow

Bootstrap a local consent instance with the public CLI and Makefile:

make init
make run-local

make init builds the binary, generates baseline config and secrets under ./config, initializes mutable runtime state under ./data, and stores a matching local operator environment with consent env create. The verification key is written to ./config/secrets/verification_key.der.

The generated ./config/config.yaml uses production defaults. make init passes --dev-mode, so it looks like this for a local dev setup:

server:
  publicURL: http://localhost:9001
  issuerDomain: localhost
  port: 9001
  devMode: true

That authored config file is only part of the runtime layout. consent config init also creates the signing key, verification key, bootstrap API key, and the directories used by the server.

Run the local dev client against that generated config with:

go run ./cmd/dev-client --config-dir ./config

After that, start the server with:

make run-local

Useful config commands:

consent config show --config-dir ./config
consent config show --resolved --config-dir ./config --data-dir ./data

Branding

The web UI shows the name, accent color, and logo in the brand section of config.yaml:

brand:
  name: Pollinator Network
  accentColor: "#7521b0"
  logo: brand/logo.svg

name appears in the header, page titles, and sign-in page. accentColor is a #rrggbb color; the interface mixes every tint it uses from it and picks readable text for buttons on it. logo is an .svg, .png, .jpg, or .webp file of at most 1 MiB, relative to the config directory unless absolute. Without a logo, the UI shows the name's first letter on the accent color. Restart the server after changing the brand.

Create the first administrator through the API. consent init creates the admin group empty, and the bootstrap API key can always add the first member. This creates alice, adds them to admin, and prints a password link:

consent api admins create alice --config-dir ./config

Open the link to choose alice's password. Until the admin group has a member, consent init ends by printing this command. To set a password directly instead, pass --password to users create, or use users password set.

Passwords are at least 8 characters. Once there is an administrator, they create accounts from the Users page of the admin dashboard. Consent shows the administrator a password link, once, to send to the person, who chooses their own password there. A link works once, for 24 hours, and making a new link replaces the old one. Making a link changes nothing about the account. Using it sets the password and signs the person out everywhere else.

To lock someone out at once, an administrator clears their password from the user's page, which signs them out everywhere and cancels their link. People change their own password, or sign out everywhere, from their account page, which the handle in the top bar leads to.

Mock Deployment

Run a full local mock deployment with one real consent server login flow and three mock browser clients:

make mock-deployment

This target requires curl and jq. It resets ./mock, creates a consent config for http://localhost:9000 using the default production mode, starts a temporary consent server with --insecure-cookies to seed demo users, groups, and three mock integrations, and then starts:

  • http://localhost:9000 for the consent server
  • http://mock1.localhost:9001
  • http://mock2.localhost:9002
  • http://mock3.localhost:9003
  • http://mock4.localhost:9004 as an unregistered app for testing admin manifest import

The mock deployment keeps the real login flow enabled while relaxing auth cookie security for local HTTP so Safari and other stricter browsers will store them on localhost.

Each mock client declares notes.read and notes.write and registers through its own manifest. The seeded groups are:

  • members (alice and bob) grants notes.read on mock 1 and mock 3.
  • writers (alice) grants notes.write on mock 1 and mock 3.
  • admin (alice) administers Consent and grants both capabilities on mock 2.

The demo credentials are:

alice / alice123   (administrator)
bob / bob123

To prepare the mock environment without starting the long-running processes, use:

make mock-deployment-init

Docker Deployment

The repository ships a compose.yaml that builds the image, generates config and secrets on first start, and runs the server:

make compose-setup      # creates ./deploy and .env
$EDITOR .env            # set CONSENT_PUBLIC_URL and CONSENT_AUTHORITY_DOMAIN
docker compose up -d

Config and secrets live in ./deploy/config and the SQLite database in ./deploy/data, bind-mounted into the containers. The server runs as CONSENT_UID:CONSENT_GID from .env, and init gives ./deploy to that user on every start. make compose-setup sets them to your user, so you can edit these files on the host: your own IDs with rootful Docker, or 0:0 with rootless Docker or Podman, where container root maps to the user running the daemon. Run it again after copying .env from another machine.

The one-shot init service runs before the server on every start. The first time, it writes config.yaml, the signing key, and the bootstrap API key into ./deploy/config; every time, it brings the database up to date. The server listens on port 8000 in the container and is published on CONSENT_BIND:CONSENT_PORT (0.0.0.0:8000 by default). The public URL and authority domain always come from .env, so changing them only takes a restart.

Create the first administrator once the server is up:

docker compose exec consent consent --config-dir /config api admins create alice

make compose-admin ADMIN_USER=alice runs the same command.

It prints a password link; open it to choose alice's password.

To see why a sign-in or an app's login fails, add --verbose to CONSENT_SERVE_FLAGS in .env and restart. The server then logs sign-ins, authorization requests, and token exchanges, with the reason for each refusal.

Run any other CLI command against the running server the same way:

docker compose exec consent consent --config-dir /config api users list

Other settings, such as branding and token lifetimes, live in ./deploy/config/config.yaml; restart with docker compose restart consent after editing it. Client backends need ./deploy/config/secrets/verification_key.der.

Deleting ./deploy deletes the signing key and every user.

Interface Design

For testability, depend on the client.Verifier interface rather than *client.Client:

type MyApp struct {
    auth client.Verifier  // Not *client.Client
}

In production, pass a *client.Client (which implements Verifier). In tests, pass a *testing.TestVerifier.

If your component needs verification, auth handlers, and live user info, use client.AuthClient. Narrower components can depend on client.Verifier, client.AuthorizationCodeHandler, or client.UserInfoFetcher independently.