Authentication
The CMS hostname is protected by the CMS backend itself: the CMS embeds an
OAuth2/OIDC client and enforces authentication on every request to its public
:8080 listener. The ingress in front of the CMS only terminates TLS and
carries no authentication annotations. There is no separate ingress auth
adapter and no HTTP Basic mechanism. A Website can reference an explicit Secret
(spec.cms.authentication.secretRef.name) or use
automatic Ipseity authentication when Ipseity is installed.
Examples below use viking-cms as the site namespace, matching the shipped
manifests. If using websites-example from the installation guide, change the
Secret and Website together. YAML fragments starting with cms: belong under
spec: of a Website, not at the manifest root. Register the actual callback
hostname with your IdP before applying; example.net values are placeholders.
For acronyms use the glossary.
Operator-level configuration
For explicit authentication, configure the reference on the Website:
cms:
hostname: cms.example.net
authentication:
secretRef:
name: example-cms-oauth2
Omit cms.hostname to default to cms.<spec.domain>. Omitting authentication
enables automatic Ipseity provisioning when available; an explicit reference
always takes precedence. Use cms.enabled: false for frontend-only Websites.
The CMS hostname must not equal any public hostname of the same Website
(domain + expanded aliases) — two Ingress rules for the same hostname would
collide. Subdomains are allowed because ingress hostname matching is exact, so
cms.example.com is accepted when the site's public domain is example.com;
serving the CMS at a distinct hostname does not shadow the website.
The operator then:
- validates the referenced Secret (required keys; see below) and surfaces
problems through the
Readycondition — a missing or malformed Secret means the CMS is not ready. For an existing site the operator blocks CMS Service routing and scales the CMS Deployment to zero, preventing stale credentials from remaining active. Valid configuration restores routing and the Deployment; the public frontend and storage are retained; - mounts the Secret read-only into the CMS pod at
/etc/viking/auth(one file per key,defaultMode0444); - sets
VIKING_AUTH_SECRET_DIR=/etc/viking/authandVIKING_COOKIE_SECURE=trueon the CMS Deployment (session/state cookies are alwaysSecure); - generates the CMS Ingress as a plain routing Ingress (TLS offload only);
- fingerprints the Secret data in the CMS pod template, triggering a
Recreaterollout when configuration, credentials or role policy change.
The CMS loads the configuration from the mounted files. If it is missing or
invalid the CMS is not ready: the admin /readyz reports 503 and the
protected surface returns 503 — the CMS never runs unauthenticated.
Transient discovery failures retry every five seconds (each attempt has a
15-second timeout). All discovered endpoints use the same HTTPS policy as
explicit endpoints, and outbound redirects are checked before they are followed.
Bearer JWT verification inherits the discovered provider's supported signing
algorithms while checking the configured audience.
Correcting the Secret restarts the CMS with the new
configuration; changing only Secret metadata does not restart it.
The authentication Secret
A Secret in the same namespace as the Website, of type Opaque, whose keys
become files in /etc/viking/auth. Required keys:
| Key | Required | Meaning |
|---|---|---|
clientID |
yes | The OAuth2/OIDC client identifier. |
jwtSecret |
yes | At least 32 random bytes, raw or base64 (padded/unpadded). The decoded/raw bytes are the HMAC-SHA256 signing key; the CMS derives a separate AES-256-GCM key. Prefer openssl rand -base64 32 and store the result as text. Raw random bytes can contain whitespace trimmed by configuration loading, so base64 is the reliable provisioning form. |
discoveryURL |
exactly one of the two paths | Provider's issuer / base URL, e.g. https://idp.example.com (the CMS appends /.well-known/openid-configuration). Preferred. |
authURL + tokenURL + jwksURL |
exactly one of the two paths | Explicit authorization, token and JWKS endpoints (with issuer). Use when the provider has no discovery document. Must not be combined with discoveryURL. |
clientSecret |
unless publicClient is "true" |
The OAuth2 client secret (confidential client). |
Optional keys:
| Key | Default | Meaning |
|---|---|---|
publicClient |
— | Exactly "true" selects the Authorization Code + PKCE flow and drops the clientSecret requirement. |
issuer |
— | Expected issuer; required when explicit endpoints are used. |
userinfoURL |
from discovery | Userinfo endpoint; used as the identity fallback when the provider returns no ID token. |
introspectionURL |
— | RFC 7662 endpoint; enables validation of opaque Bearer tokens. |
scopes |
openid email profile |
Space/comma separated list requested in the auth flow. |
sessionTTL |
1h |
Lifetime of the session cookie (Go duration, e.g. 1h, 24h). |
cookieName |
viking_session |
Session cookie name. |
cookieDomain |
— | Optional Domain attribute on the session cookie. |
rolesClaim / emailClaim / nameClaim |
roles / email / name |
Claim names used to build the identity. |
audience |
client id | Expected aud for Bearer JWTs. |
allowedRoles |
empty | Space/comma RBAC allowlist; empty = any authenticated user. |
publishRoles |
empty | Space/comma list that additionally gates POST /api/publish and POST /api/publish/rollback: the user must hold at least one of these roles (403 otherwise). Empty = any allowedRoles user may publish. This is a publish-specific gate layered on top of allowedRoles. |
cmsHostname |
— | External CMS hostname used to validate the request Host when building OAuth2 redirect URIs; also settable via --cms-hostname / VIKING_CMS_HOSTNAME (the flag wins over the secret key). |
Example (see deploy/examples/06-cms-oauth2-secret.yaml):
apiVersion: v1
kind: Secret
metadata:
name: example-cms-oauth2
namespace: viking-cms
type: Opaque
stringData:
clientID: my-cms-client
jwtSecret: REPLACE_WITH_RANDOM_BASE64_KEY
discoveryURL: https://auth.example.com
clientSecret: <client-secret>
scopes: openid email profile
sessionTTL: 1h
allowedRoles: editors admins
publishRoles: admins
The example file intentionally ships an invalid placeholder for
jwtSecret(<...>), so the operator rejects the configuration (InvalidSecret) and can never provision a CMS with a forgeable or weak session key.Secrets referenced here are treated as the source of the OAuth2 client credentials. Rotate
jwtSecretto invalidate all outstanding browser sessions; rotateclientSecretat the IdP in the usual way.
stringData accepts plain text and Kubernetes encodes it for Secret storage.
Do not base64-encode the entire YAML value again yourself when using stringData.
If using the data field instead, Kubernetes requires base64 of each file's
text; that is a distinct encoding layer from optional base64 key material inside
jwtSecret. The application reads trimmed file values. Keep the Secret manifest
out of source control and back it up through your secure credential workflow.
Identity provider requirements
- Redirect URI. The provider must allow the callback
https://<cms-hostname>/oauth2/callback(the hostname fromspec.cms.hostname; the CMS derives it from the request, always over HTTPS when cookies are Secure). - Endpoints must be HTTPS. All IdP endpoints (
discoveryURL,authURL,tokenURL,jwksURL,issuer,userinfoURL,introspectionURL) must behttps://. Plainhttp://is accepted only for loopback hosts (localhost,127.0.0.1,[::1]) and only when the CMS runs with--allow-insecure-auth-http/VIKING_ALLOW_INSECURE_AUTH_HTTP=true— a development-only escape hatch. Without it the CMS refuses to start rather than let a MITM swap the JWKS (→ token forgery) or inject roles through an http introspection endpoint. - Confidential vs public client. With a
clientSecretthe CMS uses the confidential Authorization Code flow. For a public client (no secret) setpublicClient: "true"and the CMS uses Authorization Code + PKCE (S256), so no client secret is stored or sent. - Scopes. Default
openid email profile; add provider-specific scopes viascopesif needed for therolesClaimvalues. - Logout (optional). If the provider advertises an
end_session_endpointin discovery,POST /oauth2/logoutclears the local session and returns the provider's end-session URL (with the retained ID token asid_token_hintand apost_logout_redirect_uriback to/).
Sign-in behaviour
GET /oauth2/startbegins the flow (browser redirect to the IdP). The login state is bound to the browser by a mandatoryviking_oauth_state_<state>cookie (login CSRF / account fixation protection). Each pending login has its own host-only cookie, scoped to/oauth2/callbackand valid for ten minutes, so another login cannot overwrite a flow awaiting consent.GET /oauth2/callbackexchanges the code, validates the ID token (issuer, audience, expiry, nonce) or falls back to userinfo, enforces RBAC, and sets theviking_sessioncookie.- Session cookie —
viking_session(configurable viacookieName): a stateless, encrypted valuebase64url(nonce‖AES-256-GCM-encrypted-claims).base64url(HMAC-SHA256(jwtSecret, blob)). The claims (email,name,roles,iss/sub,exp/iat,jti) are AES-256-GCM encrypted with a key derived fromjwtSecretand the whole blob is HMAC-SHA256 signed, so the cookie is opaque to anyone who can read it.HttpOnly,SameSite=Lax,Secure, TTLsessionTTL. - Unauthenticated
/api/*→401JSON; a browser navigating to the CMS is redirected to/oauth2/start; auth misconfiguration →503.
Logout
Logout is a CSRF-protected POST:
POST /oauth2/logout— requires the CSRF session cookie (viking_csrf) plus a matchingX-CSRF-Tokenheader and a same-originOrigin(orReferer) header, exactly like/api/*mutations. On success the session cookie is cleared and the response is200 {"logoutURL": "<idp end-session URL>"}(or{"logoutURL": "/"}when the provider exposes noend_session_endpoint). The editor's Log out button performs this POST withcredentials: 'same-origin'and then navigates tologoutURL.- A
GETrequest to/oauth2/logoutreturns405and performs no side effect (there is no CSRF-unprotected logout).
Roles and RBAC
- Identity roles come from
rolesClaim(a JSON array of strings or a space/comma string). - With
allowedRolesset, the user must have at least one matching role; denial returns403. With it empty (or absent), any authenticated user passes. - Publishing is gated further by
publishRoleswhen set:POST /api/publishandPOST /api/publish/rollbackrequire at least one of those roles (403otherwise); unset, anyallowedRolesuser may publish. - RBAC applies to browser sessions and Bearer tokens alike.
Bearer tokens (machine clients)
Clients that are not browsers (CI, scripts, integrations) authenticate with
Authorization: Bearer <token>. A Bearer token wins over any session cookie:
- JWT — verified against the provider JWKS; issuer always checked, audience
is
audiencewhen set else the client id. - Opaque token — when
introspectionURLis set and JWT verification fails, the token is POSTed to the RFC 7662 endpoint (client-credentials basic auth) and must come backactive.
Token roles are subject to the same allowedRoles/publishRoles RBAC.
Verified Bearer requests can mutate /api/* without a CSRF cookie or token;
the exemption is based on the authenticated identity, never just a header's
presence. Same-origin checks still apply when Origin/Referer is supplied.
Browser session requests retain the cookie/token requirement. CI obtains its
access token from the IdP using a dedicated service client's client credentials
grant. See CI content uploads for setup and runnable examples.
Upload allowlist
Uploads go through a strict allowlist (.png .jpg .jpeg .gif .webp .avif .bmp .ico .txt .json). Executable content is rejected: .js, .mjs and .css
extensions, and text/javascript / application/javascript / text/css
content types, return 415 even when the filename carries an image extension —
the CMS origin is authenticated and would otherwise be a latent stored-script
primitive. Normal SVG uploads are also rejected (415). Explicit raw=true uploads
accept arbitrary bytes only as octet-stream attachments, as documented in
raw objects. Non-image uploads are served
with Content-Disposition: attachment so they are never rendered inline.
TLS offload at the ingress
The CMS Ingress is a plain routing Ingress: TLS terminates at the ingress,
which forwards plain HTTP to the CMS Service on port 80 → 8080. The ingress
carries no auth annotations (no nginx-style auth-* annotations). The
CMS only ever runs behind TLS and its
session/state cookies are Secure; the certificate is managed by the ingress
as usual. Do not expose the CMS Service outside the cluster without the
ingress.
Certificates are provisioned by cert-manager: the operator generates a
spec.tls block (certificate Secret <ingress>-tls, for example
example-cms-tls) and, when configured, the cert-manager.io/issuer /
cert-manager.io/cluster-issuer annotations from spec.ingress.issuer /
spec.ingress.cluster_issuer. TLS is enabled by default
(spec.ingress.tls); if you set tls: false, put another TLS-terminating
proxy in front of the CMS, because its session cookies are always Secure
and plain-HTTP sign-in would break.
Worked example
-
Register an OAuth2/OIDC client at your IdP with redirect URI
https://cms.example.net/oauth2/callbackand the scopes you need. Note the client ID, client secret, and either the discovery URL or the explicit endpoints (all HTTPS). -
Create the Secret (see the example above). At minimum:
clientID,jwtSecret(at least 32 random bytes, preferably base64),discoveryURL(or the three explicit endpoints plusissuer), andclientSecret(unlesspublicClient: "true"). -
Reference it on the
Website:cms: hostname: cms.example.net authentication: secretRef: name: example-cms-oauth2 -
Apply the
Website. The operator validates the Secret, mounts it, and the CMS becomes ready only when the OAuth2/OIDC provider is reachable and the configuration is valid. -
Open
https://cms.example.net— you are redirected to the IdP, sign in, and land back in the editor with the identity chip shown.
Introspection responses must include active: true, iss matching the configured
provider issuer, and aud (a string or array) containing the configured
audience, or the client ID when no audience is configured. Present exp and
nbf claims are also enforced. Active responses with missing or mismatched
issuer/audience are rejected, including JWTs rejected by local verification.
Configure the provider to include these claims before upgrading clients that use
opaque tokens. Browser sessions and ordinary JWT verification are unaffected by
this introspection requirement.
