Skip to content
All articles

Designing multi-tenant OIDC issuers for Kubernetes

Why each Nauthera organization is its own OIDC issuer with its own keys, and how namespaces decide which tenant a Kubernetes resource configures.

By Michael Beutler, FounderPublished

Put several teams or customers on one identity provider and you have to decide what a tenant is. There are two common designs. One issuer serves everyone and tokens carry a tenant claim. Or every tenant is an issuer of its own, with its own discovery document and signing keys.

Nauthera takes the second design: an organization is its own OpenID Connect issuer. On Kubernetes that raises a second question, which the first design never has to answer: which namespaces may configure which tenant. This article covers both, with the manifests to try them.

One issuer per tenant

For a server whose issuer is https://auth.example.com, the main organization is served at the root and every other organization under /orgs/{slug}:

Main organizationOrganization acme
Issuerhttps://auth.example.comhttps://auth.example.com/orgs/acme
Discovery/.well-known/openid-configuration/orgs/acme/.well-known/openid-configuration
JWKS/.well-known/jwks.json/orgs/acme/.well-known/jwks.json
Token/token/orgs/acme/token

A tenant's metadata is also served at the path-inserted forms RFC 8414 §3.1 defines, such as /.well-known/oauth-authorization-server/orgs/acme.

The reason is the relying party. OpenID Connect requires a client to check that an ID token's iss exactly matches the issuer it was configured with (OpenID Connect Core 1.0 §3.1.3.7), and to verify the signature with the keys that issuer publishes. A relying party that does just that rejects another tenant's token without any tenant logic of its own: the issuer differs and the keys do not verify. With a shared issuer and a tenant claim, every relying party has to check the claim as well, and the one that forgets accepts tokens from every tenant.

The issuer is only the visible part. A client registered in one organization does not exist in another; a sign-in session is pinned to its organization and never satisfies an authorization request for a different one; and an email address is unique within an organization, not across the server. Policy is per organization too: FAPI mode, a verified-address requirement, a second-factor policy and a default branding.

Keys per tenant

Every organization other than the main one gets two signing keys when it is created, an EC P-256 key for ES256 and an RSA-2048 key for RS256. Both exist because OpenID Connect Discovery requires RS256 among the ID token signing algorithms an issuer supports (OpenID Connect Discovery 1.0 §3), and every organization publishes its own discovery document. Each key's kid is its RFC 7638 thumbprint.

The keys are stored in the database, sealed with AES-256-GCM under the deployment's key-encryption key (keys.encryption_key), with the organization's ID bound in as associated data. A sealed key copied into another organization's row fails to open rather than signing for the wrong tenant. The main organization signs with the keys configured under keys.* instead. One consequence for operations: a database backup is only restorable with its key-encryption key, or every organization but the main one loses its signing keys.

Declaring a tenant

With the operator, an organization is a cluster-scoped Organization resource. It is cluster-scoped because creating a tenant is not a decision one namespace may make for the whole deployment. Here are two tenants that share a Grafana namespace:

apiVersion: nauthera.io/v1alpha1
kind: Organization
metadata:
  name: acme
spec:
  slug: acme
  displayName: Acme Inc.
  namespaces: [acme, grafana]
---
apiVersion: nauthera.io/v1alpha1
kind: Organization
metadata:
  name: globex
spec:
  slug: globex
  displayName: Globex
  namespaces: [globex, grafana]

The server decides whether the operator may create them. Its mutual-TLS identity needs the organizations operation and each slug in the server's grant. The Helm chart's default grants the main organization only, so extend it:

certManager:
  grpcTLS:
    allowedClients:
      - identity: dns:nauthera-operator
        operations: [clients, brandings, identity_providers, ldap_directories, organizations]
        organizations: [default, acme, globex]
        namespaces: ["*"]

The operator creates each organization, or adopts the one that already holds the slug, and records its issuer:

kubectl get organization acme -o jsonpath='{.status.issuer}{"\n"}'
curl -s https://auth.example.com/orgs/acme/.well-known/openid-configuration | jq -r .issuer

Both print https://auth.example.com/orgs/acme.

Namespaces decide the tenant

A namespaced resource, such as an OIDCClient, configures the organization that admits its namespace in spec.namespaces. The main organization, which has no resource, admits the namespaces listed in the chart's operator.mainOrganizationNamespaces. A client in the acme namespace needs to name nothing:

apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: portal
  namespace: acme
spec:
  clientId: portal
  redirectUris: [https://portal.acme.example/callback]
  grantTypes: [authorization_code]
  scopes: [openid, profile, email]
  secret:
    generate: true
---
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: grafana-acme
  namespace: grafana
spec:
  organization: acme
  clientId: grafana-acme
  redirectUris: [https://grafana.example.com/login/generic_oauth]
  grantTypes: [authorization_code]
  scopes: [openid, profile, email]
  secret:
    generate: true

The grafana namespace is admitted by both tenants, so a resource there has to say which it means with spec.organization. Without it, the operator sends nothing and reports OrganizationAmbiguous. The other outcomes follow the same pattern:

SituationReason on the resource
No organization admits the namespace, or the named one does notNamespaceNotAdmitted
Several admit it and spec.organization names noneOrganizationAmbiguous
The admitting organization has no issuer yetOrganizationNotReady, phase Pending
The server holds the resource in another organizationOrganizationChanged

The Secret the operator writes for each client carries the organization's issuer under the key issuer, so the application configures itself for its tenant without anyone pasting a URL. spec.organization can be set once on a resource that named none, then never changed, and status.organization records where the server holds the client. Moving a client to another tenant means deleting and recreating it: the server keys a client by its organization and refuses a write that would move one.

Who may draw the lines

Labelling a namespace grants nothing. Only someone who may edit the cluster-scoped Organization, or the Helm release, can admit a namespace to a tenant. The chart's nauthera-platform role writes Organization resources and is bound cluster-wide, so it belongs to the fewest people; nauthera-app-developer, bound per namespace, writes clients and brandings. A client reaches the server only after three independent checks: Kubernetes RBAC on the resource, the organization's admission of its namespace, and the server's grant on the operator's identity.

Deleting a tenant is guarded the same way. spec.deletionPolicy defaults to Retain, which leaves the organization serving when its resource is deleted, and the definition refuses any change to it after creation, so a later edit cannot arm a deletion. The slug and FIPS mode are fixed at creation too: another slug is another organization, and switching FIPS mode would strand every password hash the organization already holds.

What it costs

Separate issuers make the safe behavior the default, and they have costs. A person who works with two tenants holds two accounts. Each relying party must be configured with its own tenant's issuer, which the operator's Secret handles for clients it registers. A user signs in at their organization's address; sending an email address to the organization that owns its domain is what the identity broker is for.

The Organizations page lists every URL and per-tenant setting, the Organization reference the grant a tenant needs, and Which organization a resource configures the full decision.

Hear when the operator ships

One email when the operator ships. No newsletter.

By joining, you agree to our Privacy Policy and Terms.