From 96e207b18588871bfe2f54fe1033efefb1bb5cd5 Mon Sep 17 00:00:00 2001 From: Stefan Bodewig Date: Wed, 4 Jul 2018 15:47:33 +0200 Subject: [PATCH 1/2] optionally extract user from id_token in oidc provider --- README.md | 1 + main.go | 1 + options.go | 2 + providers/oidc.go | 14 ++++ providers/oidc_test.go | 134 +++++++++++++++++++++++++++++++++++++ providers/provider_data.go | 1 + 6 files changed, 153 insertions(+) create mode 100644 providers/oidc_test.go diff --git a/README.md b/README.md index 03c753cce..3b4cc7f9e 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ Usage of oauth2_proxy: -tls-cert string: path to certificate file -tls-key string: path to private key file -upstream value: the http url(s) of the upstream endpoint or file:// paths for static files. Routing is based on the path + -username-claim string: Claim of the id_token that contains the user name when using oidc provider -validate-url string: Access token validation endpoint -version: print version string ``` diff --git a/main.go b/main.go index 287dc4894..dcb33faa5 100644 --- a/main.go +++ b/main.go @@ -77,6 +77,7 @@ func main() { flagSet.String("resource", "", "The resource that is protected (Azure AD only)") flagSet.String("validate-url", "", "Access token validation endpoint") flagSet.String("scope", "", "OAuth scope specification") + flagSet.String("username-claim", "", "id_token claim containing the user name") flagSet.String("approval-prompt", "force", "OAuth approval_prompt") flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)") diff --git a/options.go b/options.go index 949fbba80..b1fc45d53 100644 --- a/options.go +++ b/options.go @@ -72,6 +72,7 @@ type Options struct { ProtectedResource string `flag:"resource" cfg:"resource"` ValidateURL string `flag:"validate-url" cfg:"validate_url"` Scope string `flag:"scope" cfg:"scope"` + UsernameClaim string `flag:"username-claim" cfg:"username_claim"` ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` RequestLogging bool `flag:"request-logging" cfg:"request_logging"` @@ -250,6 +251,7 @@ func parseProviderInfo(o *Options, msgs []string) []string { ClientID: o.ClientID, ClientSecret: o.ClientSecret, ApprovalPrompt: o.ApprovalPrompt, + UsernameClaim: o.UsernameClaim, } p.LoginURL, msgs = parseURL(o.LoginURL, "login", msgs) p.RedeemURL, msgs = parseURL(o.RedeemURL, "redeem", msgs) diff --git a/providers/oidc.go b/providers/oidc.go index 0c0fa52a9..c5ce48285 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -63,11 +63,25 @@ func (p *OIDCProvider) Redeem(redirectURL, code string) (s *SessionState, err er return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) } + user := "" + if p.UsernameClaim != "" { + claimsMap := make(map[string]interface{}) + if err := idToken.Claims(&claimsMap); err != nil { + return nil, fmt.Errorf("failed to parse id_token claims: %v", err) + } + if u, ok := claimsMap[p.UsernameClaim]; ok { + if user, ok = u.(string); !ok { + return nil, fmt.Errorf("id_token claim %s's value is not a string", p.UsernameClaim) + } + } + } + s = &SessionState{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresOn: token.Expiry, Email: claims.Email, + User: user, } return diff --git a/providers/oidc_test.go b/providers/oidc_test.go new file mode 100644 index 000000000..b8a8e82c4 --- /dev/null +++ b/providers/oidc_test.go @@ -0,0 +1,134 @@ +package providers + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + oidc "github.com/coreos/go-oidc" + "github.com/stretchr/testify/assert" +) + +func newJsonReturningRedeemServer(body []byte) (*url.URL, *httptest.Server) { + s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header()["Content-Type"] = []string{"application/json"} + rw.Write(body) + })) + u, _ := url.Parse(s.URL) + return u, s +} + +var issuer string = "https://exmple.org/" + +type testVerifier struct { +} + +func (t *testVerifier) VerifySignature(ctx context.Context, jwt string) ([]byte, error) { + parts := strings.Split(jwt, ".") + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("oidc: malformed jwt: %v", err) + } + return payload, nil +} + +func newOidcProvider() *OIDCProvider { + p := NewOIDCProvider( + &ProviderData{ + ProviderName: "", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: ""}) + p.Verifier = oidc.NewVerifier(issuer, &testVerifier{}, &oidc.Config{ + SkipClientIDCheck: true, + SkipExpiryCheck: true, + }) + return p +} + +// reusing redeemResponse from google_test + +func TestUserIsBlankByDefault(t *testing.T) { + p := newOidcProvider() + body, err := json.Marshal(redeemResponse{ + AccessToken: "a1234", + ExpiresIn: 10, + RefreshToken: "refresh12345", + IdToken: base64.RawURLEncoding.EncodeToString([]byte(`{"typ": "jwt", "alg": "none"}`)) + "." + base64.RawURLEncoding.EncodeToString([]byte("{\"iss\": \""+issuer+"\", \"email\": \"jane.doe@example.org\", \"email_verified\":true}")) + ".", + }) + assert.Equal(t, nil, err) + var server *httptest.Server + p.RedeemURL, server = newJsonReturningRedeemServer(body) + defer server.Close() + + session, err := p.Redeem("http://redirect/", "code1234") + assert.Nil(t, err) + assert.NotNil(t, session) + assert.Equal(t, "", session.User) +} + +func TestUserIsBlankIfConfiguredClaimIsMissing(t *testing.T) { + p := newOidcProvider() + p.ProviderData.UsernameClaim = "username" + body, err := json.Marshal(redeemResponse{ + AccessToken: "a1234", + ExpiresIn: 10, + RefreshToken: "refresh12345", + IdToken: base64.RawURLEncoding.EncodeToString([]byte(`{"typ": "jwt", "alg": "none"}`)) + "." + base64.RawURLEncoding.EncodeToString([]byte("{\"iss\": \""+issuer+"\", \"email\": \"jane.doe@example.org\", \"email_verified\":true}")) + ".", + }) + assert.Equal(t, nil, err) + var server *httptest.Server + p.RedeemURL, server = newJsonReturningRedeemServer(body) + defer server.Close() + + session, err := p.Redeem("http://redirect/", "code1234") + assert.Nil(t, err) + assert.NotNil(t, session) + assert.Equal(t, "", session.User) +} + +func TestUserIsSetFromConfiguredClaimIfPresent(t *testing.T) { + p := newOidcProvider() + p.ProviderData.UsernameClaim = "username" + body, err := json.Marshal(redeemResponse{ + AccessToken: "a1234", + ExpiresIn: 10, + RefreshToken: "refresh12345", + IdToken: base64.RawURLEncoding.EncodeToString([]byte(`{"typ": "jwt", "alg": "none"}`)) + "." + base64.RawURLEncoding.EncodeToString([]byte("{\"iss\": \""+issuer+"\", \"email\": \"jane.doe@example.org\", \"email_verified\":true, \"username\": \"jd\"}")) + ".", + }) + assert.Equal(t, nil, err) + var server *httptest.Server + p.RedeemURL, server = newJsonReturningRedeemServer(body) + defer server.Close() + + session, err := p.Redeem("http://redirect/", "code1234") + assert.Nil(t, err) + assert.NotNil(t, session) + assert.Equal(t, "jd", session.User) +} + +func TestAnErrorIsReturnedIfConfiguredUsernameClaimIsNotStringValued(t *testing.T) { + p := newOidcProvider() + p.ProviderData.UsernameClaim = "username" + body, err := json.Marshal(redeemResponse{ + AccessToken: "a1234", + ExpiresIn: 10, + RefreshToken: "refresh12345", + IdToken: base64.RawURLEncoding.EncodeToString([]byte(`{"typ": "jwt", "alg": "none"}`)) + "." + base64.RawURLEncoding.EncodeToString([]byte("{\"iss\": \""+issuer+"\", \"email\": \"jane.doe@example.org\", \"email_verified\":true, \"username\": true}")) + ".", + }) + assert.Equal(t, nil, err) + var server *httptest.Server + p.RedeemURL, server = newJsonReturningRedeemServer(body) + defer server.Close() + + _, err = p.Redeem("http://redirect/", "code1234") + assert.NotNil(t, err) +} diff --git a/providers/provider_data.go b/providers/provider_data.go index 92e27dd7a..f10ba0307 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -15,6 +15,7 @@ type ProviderData struct { ValidateURL *url.URL Scope string ApprovalPrompt string + UsernameClaim string } func (p *ProviderData) Data() *ProviderData { return p } From b5cf289faf8599098363e2fad56f378735413533 Mon Sep 17 00:00:00 2001 From: Stefan Bodewig Date: Thu, 5 Jul 2018 09:18:43 +0200 Subject: [PATCH 2/2] fix test names --- providers/oidc_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/providers/oidc_test.go b/providers/oidc_test.go index b8a8e82c4..b0764db2e 100644 --- a/providers/oidc_test.go +++ b/providers/oidc_test.go @@ -56,7 +56,7 @@ func newOidcProvider() *OIDCProvider { // reusing redeemResponse from google_test -func TestUserIsBlankByDefault(t *testing.T) { +func TestOidcProviderLeavesUserlankByDefault(t *testing.T) { p := newOidcProvider() body, err := json.Marshal(redeemResponse{ AccessToken: "a1234", @@ -75,7 +75,7 @@ func TestUserIsBlankByDefault(t *testing.T) { assert.Equal(t, "", session.User) } -func TestUserIsBlankIfConfiguredClaimIsMissing(t *testing.T) { +func TestOidcProviderLeavesUserBlankIfConfiguredClaimIsMissing(t *testing.T) { p := newOidcProvider() p.ProviderData.UsernameClaim = "username" body, err := json.Marshal(redeemResponse{ @@ -95,7 +95,7 @@ func TestUserIsBlankIfConfiguredClaimIsMissing(t *testing.T) { assert.Equal(t, "", session.User) } -func TestUserIsSetFromConfiguredClaimIfPresent(t *testing.T) { +func TestOidcProviderSetsUserFromConfiguredClaimIfPresent(t *testing.T) { p := newOidcProvider() p.ProviderData.UsernameClaim = "username" body, err := json.Marshal(redeemResponse{ @@ -115,7 +115,7 @@ func TestUserIsSetFromConfiguredClaimIfPresent(t *testing.T) { assert.Equal(t, "jd", session.User) } -func TestAnErrorIsReturnedIfConfiguredUsernameClaimIsNotStringValued(t *testing.T) { +func TestOidcProviderReturnsAnErrorIfConfiguredUsernameClaimIsNotStringValued(t *testing.T) { p := newOidcProvider() p.ProviderData.UsernameClaim = "username" body, err := json.Marshal(redeemResponse{