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 organization | Organization acme | |
|---|---|---|
| Issuer | https://auth.example.com | https: |
| Discovery | / | / |
| JWKS | /.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
/.
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 .issuerBoth print https:.
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.. 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: trueThe 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:
| Situation | Reason on the resource |
|---|---|
| No organization admits the namespace, or the named one does not | NamespaceNotAdmitted |
Several admit it and spec.organization names none | OrganizationAmbiguous |
| The admitting organization has no issuer yet | OrganizationNotReady, phase Pending |
| The server holds the resource in another organization | OrganizationChanged |
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.