Skip to content

Support configuring OIDC issuer independently from serving domains #7038

Description

@marcofranssen

Feature request

Please add support to the SPIRE OIDC Discovery Provider for configuring the issuer URL/domain independently from the serving domains, so the provider can be reached through cluster-internal Kubernetes Service DNS names while still advertising the same issuer used in JWT-SVIDs.

In Kubernetes deployments, it is useful for in-cluster JWT validators to fetch OIDC discovery and JWKS from the ClusterIP Service:

https://spire-spiffe-oidc-discovery-provider.spire-server
https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local

However, JWT-SVIDs are issued with the SPIRE server jwt_issuer, commonly a public DNS name:

https://spire-oidc.example.test

Today, when the OIDC Discovery Provider is reached via the Kubernetes Service DNS name, it serves an OIDC discovery document with the request host as the issuer:

{
  "issuer": "https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local",
  "jwks_uri": "https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local/keys"
}

That discovery document is reachable, but it is not usable for validating JWT-SVIDs whose iss is the configured public SPIRE issuer:

{
  "iss": "https://spire-oidc.example.test",
  "aud": ["oauth2-proxy-test"],
  "sub": "spiffe://example.test/ns/oauth2-spire-test/sa/spire-jwt-test"
}

OIDC/JWT validators correctly reject this as an issuer mismatch.

Desired behavior

It would be helpful if the OIDC Discovery Provider could be configured with an issuer URL, or issuer-domain mapping, independently from the domains on which it serves traffic. For example:

domains = [
  "spire-spiffe-oidc-discovery-provider",
  "spire-spiffe-oidc-discovery-provider.spire-server",
  "spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local",
  "spire-oidc.example.test",
]

# Strawman API only.
issuer = "https://spire-oidc.example.test"

Then a request to:

https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local/.well-known/openid-configuration

could return:

{
  "issuer": "https://spire-oidc.example.test",
  "jwks_uri": "https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local/keys"
}

That would let in-cluster validators retrieve keys through the Kubernetes Service while still validating the token against the canonical SPIRE JWT issuer.

Why this matters

Without this, consumers have to either:

  1. Route in-cluster validation traffic through the public SPIRE OIDC domain.
  2. Use validator-specific workarounds that allow splitting issuer and JWKS URL.
  3. Disable or weaken issuer verification, which is not desirable.

For example, oauth2-proxy can be made to work only with a split configuration:

--skip-oidc-discovery=true
--oidc-issuer-url=https://spire-oidc.example.test
--oidc-jwks-url=https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local/keys

But configuring oauth2-proxy with the service DNS name as the issuer fails:

oidc: id token issued by a different provider,
expected "https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local"
got "https://spire-oidc.example.test"

Reproducible setup

The following Helm values are the relevant values generated by our Terraform module from terraform-k8s-addons/modules/spire/main.tf. Placeholders are used for cluster-specific fields.

global:
  spire:
    clusterName: example-cluster
    trustDomain: example.test
    bundleConfigMap: spire-bundle
    jwtIssuer: https://spire-oidc.example.test
    caSubject:
      country: NL
      organization: Example
      commonName: spire.example.test
    recommendations:
      enabled: true
      namespaceLayout: true
      namespacePSS: true
      priorityClassName: true
      strictMode: true
      securityContext: true
      prometheus: true
  telemetry:
    prometheus:
      enabled: true
      podMonitor:
        enabled: true

spiffe-csi-driver:
  priorityClassName: system-node-critical
  podSecurityContext:
    runAsUser: 20001
    runAsGroup: 20001
    fsGroup: 20001

spire-agent:
  podSecurityContext:
    runAsUser: 20002
    runAsGroup: 20002
    fsGroup: 20002
  waitForIt:
    securityContext:
      allowPrivilegeEscalation: false
      runAsNonRoot: true
      readOnlyRootFilesystem: true
      capabilities:
        drop:
          - ALL
      seccompProfile:
        type: RuntimeDefault
  socketAlternate:
    resources:
      requests:
        cpu: 1m
        memory: 4Mi
      limits:
        memory: 8Mi
  livenessProbe:
    initialDelaySeconds: 10
    periodSeconds: 30
  readinessProbe:
    initialDelaySeconds: 10
    periodSeconds: 30
  priorityClassName: system-node-critical
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 25%
  revisionHistoryLimit: 3

spire-server:
  revisionHistoryLimit: 3

spiffe-oidc-discovery-provider:
  enabled: true
  podSecurityContext:
    runAsUser: 20003
    runAsGroup: 20003
    fsGroup: 20003
  tls:
    spire:
      enabled: true
  config:
    additionalDomains: []
    acme:
      tosAccepted: true
      cacheDir: /run/spire
      directoryUrl: https://acme-v02.api.letsencrypt.org/directory
      emailAddress: ops@example.test

With these values, the OIDC Discovery Provider config includes the service names and public domain:

{
  "domains": [
    "spire-spiffe-oidc-discovery-provider",
    "spire-spiffe-oidc-discovery-provider.spire-server",
    "spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local",
    "spire-oidc.example.test"
  ]
}

Example manifests

The following manifests create:

  • A workload that obtains a JWT-SVID using spiffe-helper.
  • One oauth2-proxy deployment that succeeds by using the public issuer plus the internal service JWKS URL.
  • One oauth2-proxy deployment that fails by using the internal service DNS name as the issuer.
apiVersion: v1
kind: Namespace
metadata:
  name: oauth2-spire-test
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: spire-jwt-test
  namespace: oauth2-spire-test
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: spiffe-jwt-helper-config
  namespace: oauth2-spire-test
data:
  helper.conf: |
    agent_address = "/spiffe-workload-api/spire-agent.sock"
    cert_dir = "/tokens"
    daemon_mode = true

    jwt_svids = [{
      jwt_audience = "oauth2-proxy-test"
      jwt_svid_file_name = "jwt_svid.token"
    }]
---
apiVersion: v1
kind: Pod
metadata:
  name: spiffe-jwt-helper
  namespace: oauth2-spire-test
spec:
  restartPolicy: Always
  serviceAccountName: spire-jwt-test
  securityContext:
    runAsNonRoot: true
    runAsUser: 20004
    runAsGroup: 20004
    fsGroup: 20004
    seccompProfile:
      type: RuntimeDefault
  initContainers:
    - name: init-helper
      image: ghcr.io/spiffe/spiffe-helper:0.10.0
      args:
        - -config
        - /etc/spiffe-helper.conf
        - -daemon-mode=false
      volumeMounts:
        - name: spiffe-workload-api
          mountPath: /spiffe-workload-api
          readOnly: true
        - name: helper-config
          mountPath: /etc/spiffe-helper.conf
          readOnly: true
          subPath: helper.conf
        - name: tokens
          mountPath: /tokens
  containers:
    - name: reader
      image: cgr.dev/chainguard/bash:latest
      command: ["/bin/sh", "-c", "while true; do sleep 3600; done"]
      volumeMounts:
        - name: tokens
          mountPath: /tokens
          readOnly: true
    - name: spiffe-helper
      image: ghcr.io/spiffe/spiffe-helper:0.10.0
      args:
        - -config
        - /etc/spiffe-helper.conf
      volumeMounts:
        - name: spiffe-workload-api
          mountPath: /spiffe-workload-api
          readOnly: true
        - name: helper-config
          mountPath: /etc/spiffe-helper.conf
          readOnly: true
          subPath: helper.conf
        - name: tokens
          mountPath: /tokens
  volumes:
    - name: spiffe-workload-api
      csi:
        driver: csi.spiffe.io
        readOnly: true
    - name: helper-config
      configMap:
        name: spiffe-jwt-helper-config
    - name: tokens
      emptyDir:
        medium: Memory
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: oauth2-proxy-public
  namespace: oauth2-spire-test
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy-public
  namespace: oauth2-spire-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: oauth2-proxy-public
  template:
    metadata:
      labels:
        app: oauth2-proxy-public
    spec:
      serviceAccountName: oauth2-proxy-public
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
          args:
            - --http-address=0.0.0.0:4180
            - --provider=oidc
            - --skip-oidc-discovery=true
            - --oidc-issuer-url=https://spire-oidc.example.test
            - --oidc-jwks-url=https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local/keys
            - --login-url=https://unused.example.invalid/oauth/authorize
            - --redeem-url=https://unused.example.invalid/oauth/token
            - --profile-url=https://unused.example.invalid/userinfo
            - --client-id=oauth2-proxy-test
            - --client-secret=dummy
            - --cookie-secret=0123456789abcdef0123456789abcdef
            - --cookie-secure=false
            - --email-domain=*
            - --oidc-email-claim=sub
            - --upstream=static://200
            - --api-route=^/.*
            - --force-json-errors=true
            - --skip-jwt-bearer-tokens=true
            - --ssl-insecure-skip-verify=true
          ports:
            - name: http
              containerPort: 4180
---
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy-public
  namespace: oauth2-spire-test
spec:
  selector:
    app: oauth2-proxy-public
  ports:
    - name: http
      port: 4180
      targetPort: http
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: oauth2-proxy-service-issuer
  namespace: oauth2-spire-test
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy-service-issuer
  namespace: oauth2-spire-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: oauth2-proxy-service-issuer
  template:
    metadata:
      labels:
        app: oauth2-proxy-service-issuer
    spec:
      serviceAccountName: oauth2-proxy-service-issuer
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
          args:
            - --http-address=0.0.0.0:4180
            - --provider=google
            - --client-id=oauth2-proxy-test
            - --client-secret=dummy
            - --cookie-secret=0123456789abcdef0123456789abcdef
            - --cookie-secure=false
            - --email-domain=*
            - --oidc-email-claim=sub
            - --upstream=static://200
            - --api-route=^/.*
            - --force-json-errors=true
            - --skip-jwt-bearer-tokens=true
            - --ssl-insecure-skip-verify=true
            - --extra-jwt-issuers=https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local=oauth2-proxy-test
          ports:
            - name: http
              containerPort: 4180
---
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy-service-issuer
  namespace: oauth2-spire-test
spec:
  selector:
    app: oauth2-proxy-service-issuer
  ports:
    - name: http
      port: 4180
      targetPort: http

Test command

TOKEN="$(kubectl exec -n oauth2-spire-test spiffe-jwt-helper -c reader -- cat /tokens/jwt_svid.token)"

kubectl exec -n oauth2-spire-test spiffe-jwt-helper -c reader -- \
  curl -i -H "Authorization: Bearer ${TOKEN}" \
  http://oauth2-proxy-public:4180/test

kubectl exec -n oauth2-spire-test spiffe-jwt-helper -c reader -- \
  curl -i -H "Authorization: Bearer ${TOKEN}" \
  http://oauth2-proxy-service-issuer:4180/test

Observed results

The public issuer plus internal service JWKS URL succeeds:

HTTP/1.1 200 OK
Gap-Auth: spiffe://example.test/ns/oauth2-spire-test/sa/spire-jwt-test

Authenticated

The service-DNS issuer fails:

HTTP/1.1 401 Unauthorized

oauth2-proxy logs:

oidc: id token issued by a different provider,
expected "https://spire-spiffe-oidc-discovery-provider.spire-server.svc.cluster.local"
got "https://spire-oidc.example.test"

Possible implementation direction

One possible approach would be to separate:

  • Serving domains / certificate SANs.
  • Issuer URL advertised in .well-known/openid-configuration.
  • JWKS URI advertised in .well-known/openid-configuration.

That would allow the provider to serve discovery and JWKS at cluster-local Kubernetes Service names while still advertising the canonical SPIRE jwt_issuer.

I'm thinking in direction of how e.g. CORS is handled on webservers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions