Skip to content

Commit 800c57d

Browse files
authored
feat(oidc): forward id token (#9367)
* forward id token Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * address comments Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * update Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * address comments Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * update comments Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * update Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> * address comments Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com> --------- Signed-off-by: Huabing (Robin) Zhao <zhaohuabing@gmail.com>
1 parent 1706b07 commit 800c57d

27 files changed

Lines changed: 455 additions & 41 deletions

File tree

api/v1alpha1/oidc_types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,13 @@ type OIDC struct {
105105
// If the configured header is "Authorization", EG forwards the ID token using
106106
// the "Bearer " prefix. For any other header, EG forwards the raw token value.
107107
// If not specified, the ID token will not be forwarded.
108+
//
109+
// Note: when passThroughAuthHeader is enabled, this header must not be the same
110+
// as a header a JWT provider extracts from (the "Authorization" header by
111+
// default). The forwarded ID token header is owned by Envoy, and Envoy rejects
112+
// an OAuth2 configuration whose pass-through matcher keys on it.
113+
//
108114
// +optional
109-
// +notImplementedHide
110115
ForwardIDToken *OIDCTokenForwarding `json:"forwardIDToken,omitempty"`
111116

112117
// DefaultTokenTTL is the default lifetime of the id token and access token.
@@ -247,7 +252,12 @@ type OIDCCookieNames struct {
247252
// OIDCTokenForwarding defines how an OIDC token is forwarded upstream.
248253
type OIDCTokenForwarding struct {
249254
// Header is the upstream request header that will carry the ID token.
255+
// It must be a valid HTTP header name. Pseudo-headers (names starting with ":")
256+
// and the "Host" header are not allowed.
250257
// +kubebuilder:validation:MinLength=1
258+
// +kubebuilder:validation:MaxLength=256
259+
// +kubebuilder:validation:Pattern=`^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$`
260+
// +kubebuilder:validation:XValidation:rule="self.lowerAscii() != 'host'",message="header cannot be the Host header"
251261
Header string `json:"header"`
252262
}
253263

charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5731,12 +5731,24 @@ spec:
57315731
If the configured header is "Authorization", EG forwards the ID token using
57325732
the "Bearer " prefix. For any other header, EG forwards the raw token value.
57335733
If not specified, the ID token will not be forwarded.
5734+
5735+
Note: when passThroughAuthHeader is enabled, this header must not be the same
5736+
as a header a JWT provider extracts from (the "Authorization" header by
5737+
default). The forwarded ID token header is owned by Envoy, and Envoy rejects
5738+
an OAuth2 configuration whose pass-through matcher keys on it.
57345739
properties:
57355740
header:
5736-
description: Header is the upstream request header that will
5737-
carry the ID token.
5741+
description: |-
5742+
Header is the upstream request header that will carry the ID token.
5743+
It must be a valid HTTP header name. Pseudo-headers (names starting with ":")
5744+
and the "Host" header are not allowed.
5745+
maxLength: 256
57385746
minLength: 1
5747+
pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$
57395748
type: string
5749+
x-kubernetes-validations:
5750+
- message: header cannot be the Host header
5751+
rule: self.lowerAscii() != 'host'
57405752
required:
57415753
- header
57425754
type: object

charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5730,12 +5730,24 @@ spec:
57305730
If the configured header is "Authorization", EG forwards the ID token using
57315731
the "Bearer " prefix. For any other header, EG forwards the raw token value.
57325732
If not specified, the ID token will not be forwarded.
5733+
5734+
Note: when passThroughAuthHeader is enabled, this header must not be the same
5735+
as a header a JWT provider extracts from (the "Authorization" header by
5736+
default). The forwarded ID token header is owned by Envoy, and Envoy rejects
5737+
an OAuth2 configuration whose pass-through matcher keys on it.
57335738
properties:
57345739
header:
5735-
description: Header is the upstream request header that will
5736-
carry the ID token.
5740+
description: |-
5741+
Header is the upstream request header that will carry the ID token.
5742+
It must be a valid HTTP header name. Pseudo-headers (names starting with ":")
5743+
and the "Host" header are not allowed.
5744+
maxLength: 256
57375745
minLength: 1
5746+
pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$
57385747
type: string
5748+
x-kubernetes-validations:
5749+
- message: header cannot be the Host header
5750+
rule: self.lowerAscii() != 'host'
57395751
required:
57405752
- header
57415753
type: object

examples/extension-server/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.26.5
44

55
require (
66
github.com/envoyproxy/gateway v1.3.1
7-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14
7+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff
88
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff
99
github.com/urfave/cli/v2 v2.27.7
1010
google.golang.org/grpc v1.82.1

examples/extension-server/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
2121
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2222
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2323
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
24-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14 h1:7g8SJv4OrVcLT4yfkzIbsTcwLBwyLu8gKb/yCf3Loxk=
25-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14/go.mod h1:18SVzvkoF8AL2O7baVikhojMZ+7rFPh3o8tOOsBVyok=
24+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff h1:tDxhbFOO5qR1vgixjuOI5RBxASCvac4Ki3EgC4dBDPI=
25+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff/go.mod h1:H3lDamtuGa0Y80VmchcmbutXMYNPDr0j+JrLlsgyEyo=
2626
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff h1:stwP9x94QfAFs+RF+YFkSrSuTxBuVrj6Sv+PJXJkXzo=
2727
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff/go.mod h1:RgJXVdNtBhId0AeGnDEqPRSejRMoz//JumYvSTcJTvM=
2828
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ require (
1212
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
1313
github.com/docker/cli v29.6.2+incompatible
1414
github.com/dominikbraun/graph v0.23.0
15-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14
16-
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260409050421-3f47accd6e14
15+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff
16+
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260627225610-70ff85c381ff
1717
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff
18-
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260409050421-3f47accd6e14
18+
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260627225610-70ff85c381ff
1919
github.com/envoyproxy/ratelimit v1.4.1-0.20260122083618-3fb702589d36
2020
github.com/evanphx/json-patch v5.9.11+incompatible
2121
github.com/evanphx/json-patch/v5 v5.9.11

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,14 @@ github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/
147147
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
148148
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
149149
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
150-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14 h1:7g8SJv4OrVcLT4yfkzIbsTcwLBwyLu8gKb/yCf3Loxk=
151-
github.com/envoyproxy/go-control-plane v0.14.1-0.20260409050421-3f47accd6e14/go.mod h1:18SVzvkoF8AL2O7baVikhojMZ+7rFPh3o8tOOsBVyok=
152-
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260409050421-3f47accd6e14 h1:VszH+75Lfplgo/ZDOe79HOGnLHAgPHWqFjMl7AdQEWw=
153-
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260409050421-3f47accd6e14/go.mod h1:29VWPXU81Y5hg3S89D3zXhbOgqgh93Os+W911d6SxP8=
150+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff h1:tDxhbFOO5qR1vgixjuOI5RBxASCvac4Ki3EgC4dBDPI=
151+
github.com/envoyproxy/go-control-plane v0.14.1-0.20260627225610-70ff85c381ff/go.mod h1:H3lDamtuGa0Y80VmchcmbutXMYNPDr0j+JrLlsgyEyo=
152+
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260627225610-70ff85c381ff h1:uPvDnxeo1yGkZS0DKq28GprxMhfNZogPTi0+u6K8Kd0=
153+
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260627225610-70ff85c381ff/go.mod h1:v21y1Uq30hmHbNsT2JOGMJG9cF+Ls7zTfIVOBLAU1TE=
154154
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff h1:stwP9x94QfAFs+RF+YFkSrSuTxBuVrj6Sv+PJXJkXzo=
155155
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260627225610-70ff85c381ff/go.mod h1:RgJXVdNtBhId0AeGnDEqPRSejRMoz//JumYvSTcJTvM=
156-
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260409050421-3f47accd6e14 h1:128xSbKG9xp2W6JAyfb2Q2pDrEC5bhtUcfYpJZf6OdA=
157-
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260409050421-3f47accd6e14/go.mod h1://utHaGoDyMdS6rB87A76UIaRn+Ss9dS2ZJ5rM2psGU=
156+
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260627225610-70ff85c381ff h1:Co6W1/F/z5JehSpnO4GLI8xkeJzO851Y5fQ7Bfxc48M=
157+
github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260627225610-70ff85c381ff/go.mod h1:YySqCcozu0HwklKZzeX6N98q+TyqEkgX2sg7DQqiJfU=
158158
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
159159
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
160160
github.com/envoyproxy/ratelimit v1.4.1-0.20260122083618-3fb702589d36 h1:nEi1OH2qhE8NtcuBgO/uKpTw/P0nVu4i8mZvL6oD9CQ=

internal/gatewayapi/securitypolicy.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,30 @@ func validateSecurityPolicy(p *egv1a1.SecurityPolicy) error {
926926
if !hasValidJwtExtractor {
927927
return errors.New("the OIDC.PassThroughAuthHeader setting must be used in conjunction with a JWT provider that is configured to read from a header")
928928
}
929+
930+
// Envoy rejects (NACKs) an OAuth2 config whose pass_through_matcher keys on the
931+
// forward_id_token header. EG builds the pass_through_matcher from
932+
// the JWT providers' extractFrom headers (defaulting to "Authorization"), so
933+
// reject the equivalent collision here to surface a clear policy error instead
934+
// of a listener NACK.
935+
if oidc.ForwardIDToken != nil {
936+
fwdHeader := oidc.ForwardIDToken.Header
937+
for _, provider := range jwt.Providers {
938+
// When ExtractFrom is not specified, JWT (and the pass-through matcher)
939+
// falls back to the "Authorization" header.
940+
if provider.ExtractFrom == nil {
941+
if strings.EqualFold(fwdHeader, "Authorization") {
942+
return fmt.Errorf("the OIDC.ForwardIDToken header %q cannot be the Authorization header when passThroughAuthHeader is enabled and a JWT provider reads from it", fwdHeader)
943+
}
944+
continue
945+
}
946+
for _, h := range provider.ExtractFrom.Headers {
947+
if strings.EqualFold(fwdHeader, h.Name) {
948+
return fmt.Errorf("the OIDC.ForwardIDToken header %q cannot be the same as a JWT provider extractFrom header when passThroughAuthHeader is enabled", fwdHeader)
949+
}
950+
}
951+
}
952+
}
929953
}
930954

931955
basicAuth := p.Spec.BasicAuth
@@ -2021,6 +2045,7 @@ func (t *Translator) buildOIDC(
20212045
redirectPath = defaultRedirectPath
20222046
logoutPath = defaultLogoutPath
20232047
forwardAccessToken = defaultForwardAccessToken
2048+
forwardIDTokenHeader *string
20242049
refreshToken = defaultRefreshToken
20252050
passThroughAuthHeader = defaultPassThroughAuthHeader
20262051
disableTokenEncryption = false
@@ -2090,6 +2115,9 @@ func (t *Translator) buildOIDC(
20902115
if oidc.ForwardAccessToken != nil {
20912116
forwardAccessToken = *oidc.ForwardAccessToken
20922117
}
2118+
if oidc.ForwardIDToken != nil {
2119+
forwardIDTokenHeader = &oidc.ForwardIDToken.Header
2120+
}
20932121
if oidc.RefreshToken != nil {
20942122
refreshToken = *oidc.RefreshToken
20952123
}
@@ -2133,6 +2161,7 @@ func (t *Translator) buildOIDC(
21332161
RedirectPath: redirectPath,
21342162
LogoutPath: logoutPath,
21352163
ForwardAccessToken: forwardAccessToken,
2164+
ForwardIDTokenHeader: forwardIDTokenHeader,
21362165
RefreshToken: refreshToken,
21372166
CookieSuffix: suffix,
21382167
CookieNameOverrides: policy.Spec.OIDC.CookieNames,

internal/gatewayapi/securitypolicy_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,57 @@ func Test_OIDC_PassThroughAuthHeader(t *testing.T) {
706706
}),
707707
wantError: false,
708708
},
709+
{
710+
name: "forwardIDToken on a header not used by any JWT provider is ok",
711+
OIDC: egv1a1.OIDC{
712+
PassThroughAuthHeader: ToPointer(true),
713+
ForwardIDToken: &egv1a1.OIDCTokenForwarding{Header: "X-Id-Token"},
714+
},
715+
JWT: &egv1a1.JWT{
716+
Providers: []egv1a1.JWTProvider{
717+
{
718+
Name: "test",
719+
ExtractFrom: &egv1a1.JWTExtractor{
720+
Headers: []egv1a1.JWTHeaderExtractor{{Name: "X-Jwt"}},
721+
},
722+
},
723+
},
724+
},
725+
wantError: false,
726+
},
727+
{
728+
name: "forwardIDToken on a custom JWT extractFrom header is rejected",
729+
OIDC: egv1a1.OIDC{
730+
PassThroughAuthHeader: ToPointer(true),
731+
ForwardIDToken: &egv1a1.OIDCTokenForwarding{Header: "X-Jwt"},
732+
},
733+
JWT: &egv1a1.JWT{
734+
Providers: []egv1a1.JWTProvider{
735+
{
736+
Name: "test",
737+
ExtractFrom: &egv1a1.JWTExtractor{
738+
// Case-insensitive collision with the forwardIDToken header.
739+
Headers: []egv1a1.JWTHeaderExtractor{{Name: "x-jwt"}},
740+
},
741+
},
742+
},
743+
},
744+
wantError: true,
745+
},
746+
{
747+
name: "forwardIDToken on Authorization is rejected when a JWT provider defaults to it",
748+
OIDC: egv1a1.OIDC{
749+
PassThroughAuthHeader: ToPointer(true),
750+
ForwardIDToken: &egv1a1.OIDCTokenForwarding{Header: "authorization"},
751+
},
752+
JWT: &egv1a1.JWT{
753+
Providers: []egv1a1.JWTProvider{
754+
// No ExtractFrom -> defaults to the Authorization header.
755+
{Name: "test"},
756+
},
757+
},
758+
wantError: true,
759+
},
709760
}
710761

711762
for _, tt := range tests {

internal/gatewayapi/testdata/securitypolicy-with-oidc.in.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ securityPolicies:
151151
redirectURL: "https://www.example.com/foo/oauth2/callback"
152152
logoutPath: "/foo/logout"
153153
forwardAccessToken: true
154+
forwardIDToken:
155+
header: X-Id-Token # Forward the ID token on a custom header alongside the access token
154156
defaultTokenTTL: 1h
155157
refreshToken: true
156158
defaultRefreshTokenTTL: 48h

0 commit comments

Comments
 (0)