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:
- Route in-cluster validation traffic through the public SPIRE OIDC domain.
- Use validator-specific workarounds that allow splitting issuer and JWKS URL.
- 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.
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:
However, JWT-SVIDs are issued with the SPIRE server
jwt_issuer, commonly a public DNS name: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
issis 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:
Then a request to:
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:
For example, oauth2-proxy can be made to work only with a split configuration:
But configuring oauth2-proxy with the service DNS name as the issuer fails:
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.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:
spiffe-helper.Test command
Observed results
The public issuer plus internal service JWKS URL succeeds:
The service-DNS issuer fails:
oauth2-proxy logs:
Possible implementation direction
One possible approach would be to separate:
.well-known/openid-configuration..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.