Configuration Guide
1. Environment Variable Injection
The application supports overriding key configuration items with environment variables. This matters
especially in Docker/Kubernetes environments — sensitive values should not be hardcoded in
config.json.
Supported Environment Variables
| Variable | Description | Config path overridden | Example |
|---|---|---|---|
FULLA_DB_HOST | Database host | db_clients[0].host | postgres |
FULLA_DB_NAME | Database name | db_clients[0].dbname | fulla_db |
FULLA_DB_PASSWORD | Database password | db_clients[0].passwd | secret |
FULLA_REDIS_HOST | Redis host | redis_clients[0].host | redis |
FULLA_REDIS_PASSWORD | Redis password | redis_clients[0].passwd | secret |
FULLA_VUE_CLIENT_SECRET | Vue client secret | plugins[OAuth2Plugin].config.clients.vue-client.secret | ... |
For the full production environment variable list (30+ entries), see the variable table in Production Deployment; this table lists only the six core items of the injection mechanism.
How It Works
- Load hook: at startup,
loadConfiguration()inmain.ccfirst callscommon::config::ConfigManager::load(), thenConfigManager::validate(). - Parse: the base
config.jsonis read into aJson::Valueobject. - Inject: the environment variables above are checked; when present, the corresponding nodes in the
Json::Valueare updated in place. - Load: Drogon loads the modified configuration object directly via
drogon::app().loadConfigJson(config); no temporary files are written to disk.
Verification
A dedicated test, EnvInjectionVerify (EnvConfigTest.cc), guarantees this logic is correct.
2. Docker Deployment
The repository ships a docker-compose.yml that orchestrates the full stack (see Docker Deployment for details).
Service Stack
- fulla-frontend: Vue SPA + Nginx (built from the
frontend-runtimestage ofdeploy/docker/Dockerfile). - fulla-admin: admin console frontend (built from
frontends/admin/Dockerfile). - fulla-backend: Drogon backend (built from the
backend-runtimestage ofdeploy/docker/Dockerfile). - fulla-postgres: PostgreSQL 17 (schema under
apps/server/migrations/applied at backend startup viaFULLA_AUTO_MIGRATE=true). - fulla-redis: password-protected Redis 7.
- fulla-prometheus: metrics collection.
Quick Start
# 构建并启动(在仓库根目录执行)
docker compose -f deploy/docker/docker-compose.yml up -d --build
# 查看日志
docker compose -f deploy/docker/docker-compose.yml logs -f fulla-backend
# 停止
docker compose -f deploy/docker/docker-compose.yml down
Configuration Handling under Docker
docker-compose.yml mounts apps/server/config/config.json into the container read-only;
the environment section injects environment variables (see §1), and at runtime
ConfigManager::load() plus environment injection override the file defaults.
3. Storage Backend Selection
The OAuth2 plugin's config.storage_type determines the persistence backend:
storage_type | Status | Notes |
|---|---|---|
postgres | Supported (the only production backend) | Full token persistence, refresh token rotation and reuse detection. |
redis | Deprecated | Never persisted refresh tokens historically (saveRefreshToken/getRefreshToken are no-ops), so rotation and reuse detection silently fail. The mode still starts (for compatibility; it logs an ERROR at startup), but the refresh_token grant is rejected with unsupported_grant_type. Do not use in new deployments. |
memory | Test only | For unit/integration tests; not for production. |
Target architecture: Postgres as the storage layer, fronted by an online Redis L2 cache
(keyspace fulla:cache:*, configured via the cache block in config.json — enabled /
ttl_seconds / invalidation_double_delete_delay_ms; invalidation uses the delayed
double-delete, see DelayedDoubleDelete). There is no standalone Redis storage mode.
4. Issuer Configuration
config.metadata.issuer (custom config) is the single source of truth for the server's issuer
URL. OAuth2Plugin reads it once at startup and uses it consistently for:
- the
issclaim stamped on issued access tokens (authorization_code / refresh_token / client_credentials / device_code grants); issin introspection responses (backfilled from the configured value when the stored row carries none);- the discovery documents (
/.well-known/openid-configuration,/.well-known/oauth-authorization-server).
Constraints:
- Trailing slashes are normalized away automatically; do not rely on them.
- Defaults to
http://localhost:5555when unset, with aLOG_WARN. - Production deployments must configure an
https://issuer; a plaintext http issuer on a non-loopback host triggers a startup warning. - Introspection
issand the discovery documents'issuerare guaranteed byte-for-byte identical (as OIDC Discovery §3 requires).
5. Client Token-Endpoint Authentication Methods (F-017)
Each client declares, via the oauth2_clients.token_endpoint_auth_method column, how it
authenticates at /oauth2/token, /oauth2/introspect, and /oauth2/revoke:
| Value | Semantics |
|---|---|
client_secret_basic | The secret must be sent in the Authorization: Basic header; a client_secret in the body is rejected. |
client_secret_post | The secret must be sent in the POST body; the Basic header is rejected. |
none | PUBLIC client; any client_secret present is rejected. |
| NULL / empty | Legacy lenient fallback: accepts the Basic header and also a body secret (Basic→body fallback). |
When the field is omitted at creation through the registration/admin endpoints, the following defaults are stored:
PUBLICclients →none(they have no secret to begin with).CONFIDENTIALclients →client_secret_basic.
Seed clients declare it explicitly: vue-client and admin-console → none;
backend-svc → client_secret_basic. Existing clients with NULL values keep their
pre-upgrade behavior; the upgrade does not break existing deployments.
6. OIDC prompt / max_age / auth_time (F-022)
The authorization endpoint supports the prompt and max_age parameters from OIDC Core
§3.1.2.1:
prompt=none: no UI of any kind. No session → 302error=login_required; consent required →error=consent_required. Errors redirect back to the validatedredirect_uricarrying the echoedstate. Combiningnonewith other values (such asnone login) is self-contradictory and returns 400 outright.prompt=login: forces re-authentication even when a session already exists.prompt=consent: forces the consent page even when existing consent already covers the requested scopes.max_age=<seconds>: forces re-authentication if the session'sauth_time(set at login / MFA verification) is older thanmax_age.
auth_time and amr are persisted with the authorization code and included in the id_token
at redemption: auth_time (when greater than 0), amr (a JSON array when set), acr
(1 = password only, 2 = MFA). The discovery document advertises
prompt_values_supported, acr_values_supported, and related claims.
7. RP-Initiated Logout (F-027) and Session Invalidation (F-028)
/oauth2/end_session (GET + POST) terminates the server-side session. To redirect after
logout, the client must supply a post_logout_redirect_uri, and it must be one of the
client's registered redirect URIs; the client is identified by the aud claim of the
id_token_hint. The hint's signature is verified (RS256 + kid + iss/exp/sub policy,
issue #78); failed verification is rejected with 400 AUTH_INVALID_ID_TOKEN_HINT. Without
a valid hint plus a registered URI, the request is rejected with 400; on success a 302
redirect carries the echoed state, and a 200 is returned when no redirect URI is provided.
POST /oauth2/logout (the existing API logout) additionally calls session()->clear()
(F-028), so the server-side session is terminated together with access-token revocation.
8. Authentication Failure Rate Limiting (F-018)
The token / introspect / revoke / device-code polling endpoints share one in-process
sliding-window rate limiter, bucketed by (client_ip, client_id). Once failed attempts
within the rolling window (default 60s) reach max_failures (default 30), subsequent
requests return HTTP 429 with a Retry-After header and an OAuth2-style
{error, error_description} body. Only failures count; a single success resets the
counter, so normal load (and integration suites making many consecutive successful requests)
is never rate-limited.
Configured via custom_config.auth.rate_limit (all config*.json carry the defaults
explicitly):
"custom_config": {
"auth": {
"require_pkce_for_public": true,
"allow_http_redirect_uri": true,
"rate_limit": {
"max_failures": 30,
"window_seconds": 60
}
}
}
Both keys may be omitted; when the rate_limit object is missing, built-in defaults are used
(30 / 60). The limiter is a function-local singleton (RateLimiter::instance() from
libs/common/include/fulla/common/utils/RateLimiter.h); the four protected endpoints share
one counter table within the same process. This is minimal brute-force / token-probing
protection; multi-instance deployments require shared storage (Redis), which is future work.
9. JWKS Key Rotation (F-029 — Operations To-Do)
The JWKS endpoint (/.well-known/jwks.json) currently serves a single static kid,
initialized once at plugin startup from the configured JWK material
(OAuth2Plugin::initAndStart() → JwkManager::init()). Rotation is not implemented:
there is no rotation schedule, no kid rolling window, and no dual-key publication. Tokens
signed by the current key remain valid for their entire lifetime; rotating the key would
invalidate all outstanding tokens signed by its predecessor.
Production rotation is registered as an operations to-do. Until then, operators who include key compromise in their threat model should restart the server with new JWK material (and accept the invalidation of all previously issued tokens).