- Go 92.9%
- CSS 3.1%
- HTML 3%
- Makefile 0.9%
- Dockerfile 0.1%
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> |
||
|---|---|---|
| .claude/skills | ||
| cmd | ||
| internal | ||
| pkg | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| compose.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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 theVerifierinterface for protecting routes, automatic token refresh, and CSRF protection. It re-exports the protocol types an app handles, such asclient.UserInfoandclient.IntegrationManifest, so apps import only this package andpkg/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 andpkg/clientboth build on it, and it imports only the standard library.pkg/tokens: JWT token utilities includingInitClientfor creating token validators with ECDSA public keys.pkg/testing: Test utilities for consuming projects. ProvidesTestVerifier(implementsclient.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:
- The consuming backend mounts Consent auth handlers and validates Consent-issued cookies with the shared verification key.
- The Consent operator registers that backend as an integration from the manifest it publishes at
/.well-known/consent-integration, using the admin UI orconsent 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-inconsentintegration, 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 stablesubclaim and permits use of the token as a user identity.profile: requiresidentity; exposes the user's Consent handle from/api/v1/auth/userinfo.groups: requiresidentity; 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
admingroup administer Consent.consent initcreates 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.verbform such asinvoices.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:9000for the consent serverhttp://mock1.localhost:9001http://mock2.localhost:9002http://mock3.localhost:9003http://mock4.localhost:9004as 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) grantsnotes.readon mock 1 and mock 3.writers(alice) grantsnotes.writeon 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.