Hoocloak is a deliberately small OpenID Connect provider for local development. It gives browser SPAs an Authorization Code + PKCE login and gives service accounts an OAuth 2.0 client-credentials flow. APIs validate short-lived RS256 JWTs through standard discovery and JWKS.
Development only. Hoocloak keeps each realm's signing key, authorization state, refresh-token families, access-token activity metadata, and revocations in memory. A restart rotates every realm key and loses that state. Hoocloak has no durable sessions, administration UI/API, federation, MFA, registration, recovery, or production availability guarantees.
flowchart LR
SPA[React SPA] -->|Code + PKCE| IDP[Hoocloak]
Worker[Service account] -->|client_credentials + Basic| IDP
IDP -->|RS256 access JWT| SPA
IDP -->|RS256 access JWT| Worker
SPA -->|Bearer JWT| API[ASP.NET API]
Worker -->|Bearer JWT| API
API -->|Discovery + JWKS| IDP
- Quick start
- Prerequisites
- CLI reference
- Configuration
- Protocol reference
- Integrations
- Helm
- Provider login UI and themes
- Browser E2E
- State and security limits
- Contributing
- Releases
- License
The turnkey path requires only Docker with Compose and a browser that supports reserved .localhost names:
docker compose up --build --waitCompose defaults to trusted development identity selection. To exercise normal username/password authentication instead:
HOOCLOAK_LOGIN_MODE=password docker compose up --build --waitOpen http://localhost:3000/. The examples use:
| Principal | Credential | Authorization |
|---|---|---|
alice |
alice-password |
role admin, permission api.read |
bob |
bob-password |
role reader, permission api.read |
example-worker |
dev-secret |
role worker, permission api.read |
Development-realm addresses:
- Issuer:
http://hoocloak.localhost:8080/realms/development - Discovery: http://hoocloak.localhost:8080/realms/development/.well-known/openid-configuration
- JWKS: http://hoocloak.localhost:8080/realms/development/keys
- Example API: http://api.localhost:5099/api/public
Compose publishes ports on 127.0.0.1 only, waits for health, and runs every container with a read-only root filesystem, no Linux capabilities, and no-new-privileges (with memory-backed /tmp only where required). Run the provider's health client inside its container:
docker compose exec hoocloak /hoocloak health \
--url http://127.0.0.1:8080/readyStop the stack with:
docker compose down --remove-orphansIf the host does not resolve api.localhost and hoocloak.localhost to loopback, map both names to 127.0.0.1. Keep the complete realm issuer byte-for-byte identical in the browser, API, and provider.
Docker Compose quick start does not require a host Go, Node, .NET, Helm, or Bun installation. Install the following only for the corresponding source or maintainer work:
| Work | Required tools |
|---|---|
| Provider source | Go 1.26.x |
| Login UI, React example, or Playwright | Node.js 24.x and npm |
| ASP.NET API source | .NET SDK 10 |
| Chart development | Helm 4.2.3 and kubectl |
| Release/Hooversion tooling | Bun 1.3.14 |
| Service-account/API smoke commands | curl and jq |
| Browser E2E | Bash, normal POSIX utilities, Docker Compose, and a local or shared-filesystem Docker daemon that can bind-mount the generated configuration |
Build the standalone provider from the repository root before using the local CLI examples:
go build -tags no_otel -o ./hoocloak ./cmd/hoocloak| Command | Contract |
|---|---|
./hoocloak serve [--config path] |
Loads one strict YAML document before opening a socket. --config defaults to ./hoocloak.yaml. |
./hoocloak hash |
Reads exactly one nonempty newline-terminated value from stdin and writes its bcrypt hash. EOF before the newline, an empty value, or any bytes after that one line are rejected. CRLF input is accepted as one line. |
./hoocloak health [--url URL] [--timeout duration] |
Defaults to http://127.0.0.1:8080/ready and 2s. The URL must be absolute credential-free HTTP(S), timeout must be positive, redirects are not followed, and only a 2xx response succeeds. |
./hoocloak version |
Prints the embedded version. Local builds use internal/version/version; release builds stamp the released value. |
Generate a hash without placing plaintext in YAML:
printf '%s\n' 'a-local-secret' | ./hoocloak hash./hoocloak serve loads immutable YAML with unknown-field and multiple-document rejection. See the complete runnable examples/hoocloak.yaml.
| Field | Required | Meaning |
|---|---|---|
base_url |
yes | Process root URL, ending in /; realm issuers are {base_url}realms/{name}. |
listen |
yes | Provider listener as host:port. |
ui.theme_dir |
no | External theme directory. Relative YAML paths resolve from the configuration file. |
tokens.access_ttl |
yes | Positive Go duration for access JWTs and their activity metadata. |
tokens.id_ttl |
yes | Positive Go duration for ID tokens. |
tokens.refresh_ttl |
yes | Positive, absolute lifetime of a refresh-token family from its first issuance. |
realms[] |
yes | One or more isolated realms. |
realms[].name |
yes | Unique lowercase DNS-label-style name matching ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$. |
realms[].users[] |
no | Optional realm users; absent or empty is valid for service-only or empty realms. |
realms[].clients[] |
no | Optional realm SPA and service clients; absent or empty is valid. |
users[].id |
yes | Stable subject identifier; shares a per-realm namespace with client IDs. |
users[].username |
yes | Password-login name; unique per realm after Unicode case folding. |
users[].password_hash |
yes | Valid bcrypt hash, including when process-wide select mode is used. |
users[].name, email, email_verified |
no | Optional profile values. With the corresponding scope, omitted strings produce empty claim values and omitted email_verified produces false. |
users[].roles, permissions |
no | Optional unique authorization values; absent or empty is valid. Permissions are custom OAuth scope tokens, never reserved OIDC scopes. |
clients[].id, type |
yes | Stable client ID and either spa or service; ID shares the realm namespace with user IDs. |
clients[].name |
no | Display name used by the hosted login page; ID is the fallback. |
clients[].audiences |
yes | Nonempty unique JWT audiences. |
clients[].allowed_scopes |
yes | Nonempty unique allow-list of valid OAuth scope tokens. |
clients[].roles, permissions |
no | Service-account authorization values; permissions must be custom scopes. |
clients[].secret_hash |
by type | Required bcrypt hash for service; forbidden for spa. |
clients[].redirect_uris |
by type | Nonempty for spa; forbidden for service. |
clients[].post_logout_redirect_uris |
no | Exact allowed SPA post-logout redirects; forbidden for service. |
clients[].origins |
by type | Nonempty exact SPA browser origins; forbidden for service. |
Minimal shape showing both client types:
base_url: http://hoocloak.localhost:8080/
listen: 0.0.0.0:8080
tokens:
access_ttl: 5m
id_ttl: 5m
refresh_ttl: 8h
realms:
- name: development
users:
- id: alice
username: alice
password_hash: "$2b$..."
name: Alice Admin
email: alice@example.test
email_verified: true
roles: [admin]
permissions: [api.read]
clients:
- id: react-spa
type: spa
redirect_uris: [http://localhost:3000/auth/callback]
post_logout_redirect_uris: [http://localhost:3000/auth/logout/callback]
origins: [http://localhost:3000]
audiences: [hoocloak-api]
allowed_scopes: [openid, profile, email, offline_access, api.read]
- id: example-worker
type: service
secret_hash: "$2b$..."
name: Example Worker
audiences: [hoocloak-api]
allowed_scopes: [api.read]
roles: [worker]
permissions: [api.read]- Configuration
base_url, redirects, and origins are absolute credential-free HTTP(S) URLs, reject wildcards and backslashes, and allow cleartext HTTP only for loopback,localhost, and.localhosthosts. Health CLI targets are separately validated as absolute credential-free HTTP(S) URLs and may use non-local HTTP. base_urlis an exact root URL with trailing/. Redirects may have paths/query strings but no fragment. Origins have no path, query, or fragment and must use canonical lowercase scheme/host form, no trailing-dot host, and no explicit default port.listenmust contain a numeric port from 1 through 65535. Token durations must be positive. Realms must be nonempty and uniquely named.- Within a realm, user and client IDs share one exact-match namespace. Usernames reject surrounding whitespace and are unique after case folding. IDs and usernames may repeat in different realms because keys, clients, users, protocol state, and browser policy are realm-isolated.
- Bcrypt fields must parse as bcrypt hashes. Roles, permissions, audiences, and scopes reject empty, whitespace-padded, or duplicate values. Scope/permission strings must be valid printable OAuth scope tokens; permissions cannot use reserved OIDC scope names.
- Supported reserved scopes are
openid,profile,email, andoffline_access.phoneandaddressare reserved but intentionally unsupported and rejected. Any other valid token is an application scope. - SPA clients have no secret, must allow
openid, and require redirects and origins. Service clients require a bcrypt secret, forbid browser redirect/origin fields and every reserved OIDC scope, and require every allowed custom scope to appear in that client's permissions.
The YAML is loaded first; environment overrides are applied afterward and the effective configuration is validated again:
| Variable | Effect |
|---|---|
HOOCLOAK_LOGIN_MODE=password|select |
Process-wide login mode. Absent means password. Empty, padded, or other values fail startup. |
HOOCLOAK_UI_THEME_DIR=/absolute/path |
Overrides ui.theme_dir. It must be nonempty, unpadded, and absolute. |
select exposes every configured user and authenticates by ID without password verification. It records amr=["dev-select"] and is only for trusted development environments. Password mode records amr=["pwd"]. The standalone binary and Helm chart default to password; compose.yaml defaults to select.
Every endpoint below is relative to /realms/{realm} unless marked root-only.
| Endpoint | Methods | Authentication and purpose |
|---|---|---|
/.well-known/openid-configuration |
GET, HEAD | Public discovery document. |
/keys |
GET | Public realm JWKS. |
/authorize |
GET, POST | Public SPA authorization entry; registered client/redirect, response_type=code, query response mode, openid, and S256 PKCE are mandatory. |
/oauth/token |
POST | SPA authorization-code/refresh requests use the public client; service client_credentials requires HTTP Basic and rejects credentials in form/query fields. |
/userinfo |
GET, POST | Bearer access token. If an Origin is sent, it must exactly match the owning SPA client's configured origins. |
/oauth/introspect |
POST | HTTP Basic client authentication; active metadata is returned only for a token owned by that client. |
/revoke |
POST | Public-SPA or Basic-authenticated service revocation. The submitted token is handled only within the owning client and unknown/foreign tokens are not disclosed. |
/end_session |
GET, POST | Requires a validated id_token_hint; post-logout redirect must belong to that client. Terminates provider state for the verified subject and client. |
/login |
GET, POST | Internal hosted-login endpoint. GET requires an active authorization request; form POST requires the matching CSRF cookie and mode-specific controls. |
/logged-out |
GET, HEAD | Realm-relative default signed-out page. |
/assets/* |
GET, HEAD | Realm-relative embedded or external theme assets. |
/healthz |
Any | Root-only liveness response; the current handler does not restrict the HTTP method. /realms/{realm}/healthz is intentionally 404. |
/ready |
Any | Root-only readiness across configured realm stores; the current handler does not restrict the HTTP method. /realms/{realm}/ready is intentionally 404. |
Token, introspection, and revocation endpoints are POST-only. Security-sensitive scalar parameters on authorize, token, introspection, revocation, end-session, and hosted-login submissions may occur only once. Applicable form bodies are capped at 64 KiB.
- A SPA may request only its allow-listed scopes. Reserved scopes are granted as requested; each custom application scope is granted only when the selected user's
permissionsalso contains it. - A service must request a nonempty subset present in both its
allowed_scopesandpermissions. - The
permissionclaim is the granted custom-scope list.rolecomes from the user for human tokens and from the client for service tokens. Access tokens includeclient_idand the space-delimitedscope; human tokens also carrynameandpreferred_username. Service tokens do not receive human profile claims. profilecontrols standardname/preferred_usernameuserinfo and ID-token profile disclosure;emailcontrolsemail/email_verified. Userinfo always includessubfor an active matching token.offline_accessenables the SPA refresh flow; it does not make access JWTs opaque or remotely retractable.- Discovery's
claims_supportedis the standard OIDC claim advertisement. It does not enumerate every Hoocloak private access-token authorization claim such asclient_id,scope,role, orpermission.
| Artifact/response | Intended use | Important claim behavior |
|---|---|---|
| SPA access JWT | API bearer token | User subject/roles; custom granted scopes become permission; contains audience, client_id, scope, jti, and RS256 signature. |
| Service access JWT | API bearer token | Subject and client_id are the service client; client roles and requested custom scopes become role/permission; no human profile. |
| SPA ID token | Client session and logout hint, not API authorization | OIDC identity claims; profile/email disclosure follows scopes. |
| Refresh token | Rotate a SPA token family | Opaque, single-use, and bound to its original subject/client/family. |
| Userinfo | Live provider lookup | Requires active access metadata; fields are scope-conditioned and browser origin is checked when present. |
| Introspection | Owning-client live lookup | Active only while matching subject/client access metadata remains. |
- Authorization requests and their codes share a five-minute lifetime.
prompt=nonereturnslogin_requiredbecause Hoocloak has no provider SSO cookie. - A failed code exchange caused by a bad verifier, client, or redirect does not consume the code, so the correct exchange may be retried. A successful exchange is exact-once; concurrent valid redemption permits only one success.
- Refresh-family lifetime is absolute and non-sliding: rotation never extends the deadline created at first issuance. Each refresh token is single-use. Reusing an ancestor revokes the family.
- Successful rotation removes the predecessor access token's provider metadata. Family replay, revocation, or end-session removes family access metadata, causing userinfo/introspection to reject it immediately.
- End-session is scoped to the verified ID-token subject plus client. Revocation and introspection are restricted to the owning client. Revocation uses a submitted access/refresh token and client authentication rules; it does not use the logout ID-token hint.
- Metadata invalidation does not retract an already-issued self-contained access JWT from an offline API that validates signature, issuer, audience, and expiry locally. Restart/key-cache behavior has the same distinction.
The turnkey integration is the root Compose stack. Source is in examples/react-spa and uses oidc-client-ts through react-oidc-context with Authorization Code + S256 PKCE:
{
authority: "http://hoocloak.localhost:8080/realms/development",
client_id: "react-spa",
redirect_uri: `${window.location.origin}/auth/callback`,
post_logout_redirect_uri: `${window.location.origin}/auth/logout/callback`,
response_type: "code",
scope: "openid profile email offline_access api.read",
automaticSilentRenew: true,
monitorSession: false,
revokeTokensOnSignout: true,
}The example stores user data (including access, ID, and refresh tokens) and OIDC transaction state in tab-scoped sessionStorage. Sign-in and sign-out callbacks replace the callback URL with a sanitized same-origin returnTo; unsafe absolute, scheme-relative, and backslash paths fall back to /. Logout attempts token revocation before RP-initiated end-session and handles /auth/logout/callback. Static hosting must route callback/history paths to the SPA entry document; the supplied nginx.conf uses an index.html fallback. Send only the access token to APIs; decoded browser claims are diagnostic data, not authorization evidence.
Standalone source requires Node 24.x/npm and coordinated ports. The checked-in provider/API configuration expects the SPA on port 3000, while Vite's project default is 5173, so override it:
Development server alternative:
npm ci --prefix examples/react-spa
npm --prefix examples/react-spa run dev -- --port 3000Build-and-preview alternative:
npm ci --prefix examples/react-spa
npm --prefix examples/react-spa run build
npm --prefix examples/react-spa run preview -- --port 3000Run the provider on 8080 and the API on 5099 at the same time; otherwise adjust the SPA environment and the matching provider redirect/origin and API CORS settings together.
The root Compose stack is the turnkey path. The source example in examples/aspnet-api targets .NET 10 and uses standard discovery/JWKS resolution from appsettings.json:
{
"Oidc": {
"Authority": "http://hoocloak.localhost:8080/realms/development",
"Audience": "hoocloak-api"
},
"Cors": {
"Origin": "http://localhost:3000"
}
}Local HTTP is accepted only when ASPNETCORE_ENVIRONMENT=Development and the authority/origin host is loopback, localhost, or .localhost. Authority must be an absolute credential-free URL whose path is exactly /realms/<valid-realm> and has no query/fragment. CORS origin must be an exact HTTP(S) origin without credentials, path, query, or fragment. Other environments require HTTPS.
The JWT handler validates issuer, audience, signing key, lifetime, RS256, and a 30-second clock skew. Its additional access-token shape gate requires nonempty jti and scope and rejects azp, preventing ID tokens from being accepted as API access tokens.
| Endpoint | Policy | Result |
|---|---|---|
GET /api/public |
none | 200 |
GET /api/profile |
valid access token + permission=api.read |
200; missing/invalid token is 401, valid token lacking permission is 403 |
GET /api/admin |
profile policy + role=admin |
200; missing/invalid token is 401, valid non-admin token is 403 |
Standalone API source requires the .NET 10 SDK:
ASPNETCORE_ENVIRONMENT=Development \
dotnet run --project examples/aspnet-api/Hoocloak.AspNetApi.csproj \
--urls http://0.0.0.0:5099Service credentials are accepted only through HTTP Basic. The requested scope must be nonempty and be present in both the client's allow-list and permissions:
This smoke workflow requires curl and jq on the host.
TOKEN="$({ curl -fsS -u example-worker:dev-secret \
-d 'grant_type=client_credentials&scope=api.read' \
http://hoocloak.localhost:8080/realms/development/oauth/token; } | jq -er .access_token)"
curl -i -H "Authorization: Bearer $TOKEN" \
http://api.localhost:5099/api/profileThe chart in charts/hoocloak deploys exactly one pod. It must remain single-replica because each realm's RSA key and protocol state are process memory. The Deployment uses Recreate, so upgrades are disruptive: pod replacement causes downtime, rotates keys, and loses authorization requests, refresh families, access activity, and revocation state.
For shared clusters, create a complete configuration Secret in the release namespace and reference it:
kubectl create namespace hoocloak
kubectl -n hoocloak create secret generic hoocloak-config \
--from-file=config.yaml=examples/hoocloak.yaml
helm install hoocloak ./charts/hoocloak \
--namespace hoocloak \
--set existingConfigSecret=hoocloak-configexistingConfigSecretKey selects the key (default config.yaml). The chart does not create or inspect an external Secret. After changing its data, increment existingConfigSecretVersion and run helm upgrade; this changes a pod checksum and reloads the immutable subPath mount. Inline hoocloakConfig instead creates a Helm-managed Secret and changes roll the pod automatically, but hashes and all inline values are then represented in Helm release state.
existingConfigBaseURL is an operator assertion used only for Ingress validation; the chart cannot compare it with the Secret's actual base_url. Keep them identical yourself.
Published releases attach one hoocloak-<version>.tgz chart archive to the corresponding GitHub Release. Download it and install by local path:
helm install hoocloak ./hoocloak-<version>.tgz --namespace hoocloak --create-namespaceThere is no published Helm repository or OCI chart endpoint.
Ingress supports one lowercase DNS host, root path / with Prefix, and TLS covering that exact host. The asserted/configured base URL must be exactly https://<host>/:
existingConfigSecret: hoocloak-config
existingConfigBaseURL: https://login.example.test/
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: login.example.test
paths:
- path: /
pathType: Prefix
tls:
- secretName: login-example-test-tls
hosts:
- login.example.testFor inline configuration, set hoocloakConfig.base_url instead of existingConfigBaseURL.
- Default pod security: UID/GID/fsGroup
65532,fsGroupChangePolicy: OnRootMismatch,RuntimeDefaultseccomp, non-root, read-only root filesystem,privileged: false, no privilege escalation, and all Linux capabilities dropped. These contexts are configurable objects; weakening overrides weakens the chart's security posture. - A dedicated ServiceAccount is created by default, but the pod always sets
automountServiceAccountToken: false;serviceAccount.automountis compatibility-only and fixed false. Withcreate: false, setnameto an existing account or the namespacedefaultaccount is selected—still without a mounted token. - Defaults request
5mCPU and16Mimemory and limit memory to128Mi; there is intentionally no CPU limit. Requests are scheduling reservations, not universal safe hard limits. The memory limit leaves room for bursts and in-memory protocol state. - Startup, liveness, and readiness probes all execute
/hoocloak health --url http://127.0.0.1:8080/ready. Only enablement/timing fields are configurable; the chart does not probe/healthz. - Container port, listener, configuration mount, and probes stay fixed at
8080;service.portchanges only the Service-facing port. Service type/annotations are configurable. - Empty
image.taguses chartappVersion;image.digestoverrides the tag. Repository, pull policy, image pull secrets, node selector, tolerations, affinity, and topology spread constraints are configurable. - User pod labels may not replace
app.kubernetes.io/nameorapp.kubernetes.io/instance; pod annotations may not replace chart-owned configuration checksums. helm test hoocloaklaunches a hardened, tokenless pod that checks only Service/readyreachability. It is a readiness smoke, not an OIDC login/token test.
Themes are immutable deployment artifacts, not persistent state. Publish the complete theme as a small OCI image, then set theme.image.reference; the chart copies /theme into a Pod-local emptyDir before Hoocloak starts and mounts it read-only at /etc/hoocloak/theme. No PVC or StorageClass is required. The chart sets HOOCLOAK_UI_THEME_DIR automatically, including when the provider configuration comes from an existing Secret.
FROM busybox:1.37.0-uclibc@sha256:39e0df8c4d65953b55c344f017e1ff2e0031a7454b3c24e6b76d402f207e315a
COPY --chown=65532:65532 . /theme/
USER 65532:65532theme:
image:
reference: registry.example.test/hoocloak-theme@sha256:<digest>
pullPolicy: IfNotPresentThe theme image must contain /bin/cp and login.html, logged-out.html, plus assets/ below /theme. The chart requires an immutable SHA-256 digest reference for reproducible deployment and rollback inputs. imagePullSecrets apply to both application and theme images. Changing the reference recreates the single Hoocloak Pod; because the chart uses Recreate, the old Pod is removed before the new image is pulled, copied, and validated, so upgrades and rollbacks are disruptive and can remain unavailable if the new theme fails. Themes load only at process startup. The init container and application share the configured container security context; when theme-image mode is enabled, podSecurityContext.fsGroup must equal securityContext.runAsGroup so the non-root init container can write the emptyDir. Source files must be readable and directories traversable by that identity. theme.emptyDir.sizeLimit defaults to 16Mi.
For the simplest fully immutable deployment, a derived Hoocloak image may instead copy the theme directly to /etc/hoocloak/theme; set inline hoocloakConfig.ui.theme_dir: /etc/hoocloak/theme, or put the same ui.theme_dir value in the complete provider configuration selected by existingConfigSecret. The chart's dedicated theme-image mode keeps theme and provider releases independent.
The OCI theme contract replaces the former theme.existingClaim and theme.readOnly values. Before upgrading an existing PVC-backed release:
- Build and publish the PVC contents as a digest-pinned theme image using the layout above.
- Remove
theme.existingClaimandtheme.readOnlyfrom the release values. - Set
theme.image.referenceto the immutable image reference. Remove inlinehoocloakConfig.ui.theme_dirunless the same values file must also support a derived application image; the chart sets the environment override for OCI themes. - Upgrade with the complete migrated values file. If the release relied only on stored Helm values, use
helm upgrade ... --reset-values -f migrated-values.yamlso removed PVC keys are not retained.
After the upgrade succeeds, delete the old PVC separately if nothing else uses it. The chart never deletes user-owned claims.
Use Helm 4.2.3:
helm lint charts/hoocloak --strict
helm template hoocloak charts/hoocloak --namespace hoocloak > /dev/null
helm template hoocloak charts/hoocloak --namespace hoocloak \
--set existingConfigSecret=hoocloak-config \
--set existingConfigSecretKey=provider.yaml \
--set-string existingConfigSecretVersion=1 > /dev/null
helm template hoocloak charts/hoocloak --namespace hoocloak \
--set-string 'theme.image.reference=registry.example.test/hoocloak-theme@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' > /dev/null
helm template hoocloak charts/hoocloak --namespace hoocloak \
--set image.repository=registry.example.test/custom-hoocloak \
--set image.tag=1.0.8 \
--set hoocloakConfig.ui.theme_dir=/etc/hoocloak/theme > /dev/null
! helm template hoocloak charts/hoocloak --namespace hoocloak \
--set theme.image.reference=registry.example.test/hoocloak-theme:latest > /dev/null
! helm template hoocloak charts/hoocloak --namespace hoocloak \
--set theme.existingClaim=legacy-theme > /dev/null
helm template hoocloak charts/hoocloak --namespace hoocloak \
--set image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa > /dev/null
helm package charts/hoocloak --destination /tmpThe embedded hosted login UI is a small SolidJS application in ui/login. Hoocloak owns authorization-request lookup, bounded form parsing, CSRF, login-mode enforcement, authentication, redirects, and errors. The embedded UI provides labelled fields/fieldset, error role=alert with an assertive live region, invalid-field descriptions, appropriate username/password autocomplete, initial keyboard focus, visible focus rings, a labelled heading region, and decorative-image hiding.
Configure an external theme and restart Hoocloak:
ui:
theme_dir: ./themes/auroraOnly these package entries are required:
themes/aurora/
├── login.html
├── logged-out.html
└── assets/
Asset names such as theme.css, theme.js, or logo.svg are illustrative; any files below assets/ are served at {{.BasePath}}/assets/. No executable bit is required.
Both templates are Go html/template files. logged-out.html receives only .BasePath and must keep login/assets URLs realm-relative. login.html receives .BasePath, .RequestID, .Client, .CSRF, .Mode, .Username, .SelectedID, .Identities, and .Error. Startup preflight renders password mode, select mode, and logged-out mode and rejects root-relative /login or /assets/ references.
The rendered login page must contain exactly one POST form, with action exactly {{.BasePath}}/login; nested or additional POST forms fail preflight. It must contain exactly one hidden authRequestID and one hidden csrf. Security-sensitive controls (authRequestID, csrf, identity, username, password) may not be duplicated. Password mode requires a username control and a password control with type=password; select mode requires an identity control. Realm CSP blocks cross-origin scripts, styles, images, and fonts, plus inline scripts and styles. form-action allows 'self' and the configured SPA redirect origins.
Compose can layer the read-only theme override without rebuilding the image:
HOOCLOAK_THEME_DIR=/absolute/host/path/to/theme \
docker compose -f compose.yaml -f compose.theme.yaml up --build --waitHOOCLOAK_UI_THEME_DIR supports direct container runs and must be absolute. YAML is preferable outside containers because relative paths resolve beside the configuration file.
Compiled login.js and login.css are generated into internal/idp/ui/dist and embedded in the Go binary. After changing the Solid source:
npm ci --prefix ui/login
npm run build --prefix ui/loginThe root Dockerfile performs this build before compiling Hoocloak, preventing stale generated assets in images.
Install the root Node dependencies and all three browsers plus Linux system dependencies:
npm ci
npx playwright install --with-deps chromium firefox webkit
npm run e2enpm run e2e starts the password suite and then the select suite as two sequential Playwright processes. Within each process Chromium, Firefox, and WebKit share one worker because they exercise one stateful provider stack. The two processes reuse playwright-report/ and test-results/; do not treat those paths as a guaranteed combined report of both modes because the later process may replace earlier output.
Playwright allocates distinct provider/API/SPA loopback ports and a Compose project. Dynamic-port invocations in the same checkout are serialized by a checkout lock. To coordinate independent runs yourself, set all three distinct E2E_PROVIDER_PORT, E2E_API_PORT, and E2E_SPA_PORT values plus a unique COMPOSE_PROJECT_NAME; partial port overrides still use the dynamic allocation lock. PW_REUSE_SERVER=1 is an advanced exception that tells Playwright to reuse a compatible already-running SPA/server stack; the caller then owns port, configuration, mode, state isolation, and cleanup correctness.
- The example uses five-minute access/ID tokens and an eight-hour absolute refresh-family lifetime; configured values may differ.
- There is no provider SSO cookie. Every new top-level authorization request asks for password/identity selection; same-tab continuity comes from the SPA refresh token in
sessionStorage. - Every restart generates a distinct RSA key per realm and loses all in-memory state. The Development API refreshes discovery/JWKS on an unknown
kid, but a last-known-good key cache may continue accepting a pre-restart self-contained JWT until its expiry plus skew. - Provider logout/revocation immediately affects refresh, userinfo, and introspection state, but cannot retract an unexpired JWT from an offline resource server.
- Hoocloak supports Authorization Code with mandatory S256 PKCE, rotating SPA refresh tokens, RP-initiated end-session with a validated ID-token hint, token revocation, and Basic-authenticated
client_credentials. It deliberately does not claim broader OIDC/OAuth feature coverage.
Commits use Conventional Commits. Before proposing a change, run the command groups relevant to it and preserve CI parity:
# Provider correctness and security
go test -race -tags no_otel ./...
go test -tags no_otel -run '^$' -bench . -benchmem -benchtime=100ms -count=1 ./internal/config ./internal/idp
test -z "$(gofmt -l cmd internal)"
go vet -tags no_otel ./...
go mod verify
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 -tags no_otel ./...
go run github.com/securego/gosec/v2/cmd/gosec@v2.28.0 -quiet -tags no_otel ./...
# Compose and generated UI assets
docker compose config --quiet
HOOCLOAK_THEME_DIR=/absolute/theme/path \
docker compose -f compose.yaml -f compose.theme.yaml config --quiet
npm ci --prefix ui/login
npm run build --prefix ui/login
# Browser integration
npm ci
npx playwright install --with-deps chromium firefox webkit
npm run e2eChart checks are listed under Chart maintainer checks. CI additionally builds release-shaped linux/amd64 and linux/arm64 OCI images with Zstandard layers and enforces the compressed per-platform size budget.
After successful CI on main, Hooversion derives the next semantic version:
feat:creates a minor release.fix:andperf:create a patch release.type!:or aBREAKING CHANGE:footer creates a major release.- Other conventional types remain in history without forcing a release.
Hooversion updates internal/version/version and CHANGELOG.md. Its Bun 1.3.14 afterVersion hook runs scripts/sync-chart-version.ts, synchronizing chart version and appVersion. It creates chore(release): hoocloak <version> and Git tag v<version>; release commits do not recursively trigger another release.
Publication attaches hoocloak-<version>.tgz to the GitHub Release and pushes identical multi-platform images to ghcr.io/openhoo/hoocloak and openhoo/hoocloak with tags <version>, <major>.<minor>, sha-<first-7-release-commit-chars>, and latest. Images use Zstandard layers, maximum BuildKit provenance, and SBOM attestations. GitHub artifact attestation is generated and pushed for the GHCR image subject only; the Docker Hub image receives the shared BuildKit provenance/SBOM but not that separate GitHub artifact-attestation record.
Docker Hub publication requires repository secrets for a username and token. The token must permit image push to openhoo/hoocloak and public-repository reads, which BuildKit's SBOM scanner pull requires. Never document or commit their values.
A manually dispatched release defaults to dry_run=true, which previews Hooversion without commits, tags, chart/image publication, or a GitHub Release. Non-dry manual publication requires successful push CI for the exact current main SHA. If publication failed after a release commit/tag already exists, retry is accepted only when current main has the exact release subject, v<version> points to that HEAD, and the release commit's first parent has successful push CI; otherwise publication stops.
