Content Management that can withstand the plundering hoards

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:

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 jwtSecret to invalidate all outstanding browser sessions; rotate clientSecret at 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

Sign-in behaviour

Logout

Logout is a CSRF-protected POST:

Roles and RBAC

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:

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

  1. Register an OAuth2/OIDC client at your IdP with redirect URI https://cms.example.net/oauth2/callback and the scopes you need. Note the client ID, client secret, and either the discovery URL or the explicit endpoints (all HTTPS).

  2. Create the Secret (see the example above). At minimum: clientID, jwtSecret (at least 32 random bytes, preferably base64), discoveryURL (or the three explicit endpoints plus issuer), and clientSecret (unless publicClient: "true").

  3. Reference it on the Website:

    cms:
      hostname: cms.example.net
      authentication:
        secretRef:
          name: example-cms-oauth2
    
  4. 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.

  5. 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.