diff --git a/docs/2_auth.md b/docs/2_auth.md index d715c6cc..ba793bec 100644 --- a/docs/2_auth.md +++ b/docs/2_auth.md @@ -18,12 +18,16 @@ Valid providers are : - [Keycloak](#keycloak-auth-provider) - [GitLab](#gitlab-auth-provider) - [LinkedIn](#linkedin-auth-provider) +- [Microsoft Azure AD](#microsoft-azure-ad-provider) +- [OpenID Connect](#openid-connect-provider) - [login.gov](#logingov-provider) - [Nextcloud](#nextcloud-provider) - [DigitalOcean](#digitalocean-auth-provider) The provider can be selected using the `provider` configuration value. +Please note that not all provides support all claims. The `preferred_username` claim is currently only supported by the OpenID Connect provider. + ### Google Auth Provider For Google, the registration steps are: diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 3c03d6c3..e272944b 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -73,10 +73,10 @@ An example [oauth2_proxy.cfg]({{ site.gitweb }}/contrib/oauth2_proxy.cfg.example | `-oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | | | `-pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header | false | | `-pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false | -| `-pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream | true | +| `-pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true | | `-prefer-email-to-user` | bool | Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, eg. htaccess authentication. | false | | `-pass-host-header` | bool | pass the request Host Header to upstream | true | -| `-pass-user-headers` | bool | pass X-Forwarded-User and X-Forwarded-Email information to upstream | true | +| `-pass-user-headers` | bool | pass X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true | | `-profile-url` | string | Profile access endpoint | | | `-provider` | string | OAuth provider | google | | `-provider-display-name` | string | Override the provider's name with the given string; used for the sign-in page | (depends on provider) | @@ -98,7 +98,7 @@ An example [oauth2_proxy.cfg]({{ site.gitweb }}/contrib/oauth2_proxy.cfg.example | `-reverse-proxy` | bool | are we running behind a reverse proxy, controls whether headers like X-Real-Ip are accepted | false | | `-scope` | string | OAuth scope specification | | | `-session-store-type` | string | [Session data storage backend](configuration/sessions); redis or cookie | cookie | -| `-set-xauthrequest` | bool | set X-Auth-Request-User and X-Auth-Request-Email response headers (useful in Nginx auth_request mode) | false | +| `-set-xauthrequest` | bool | set X-Auth-Request-User, X-Auth-Request-Email and X-Auth-Request-Preferred-Username response headers (useful in Nginx auth_request mode) | false | | `-set-authorization-header` | bool | set Authorization Bearer response header (useful in Nginx auth_request mode) | false | | `-signature-key` | string | GAP-Signature request signature key (algorithm:secretkey) | | | `-silence-ping-logging` | bool | disable logging of requests to ping endpoint | false | diff --git a/oauthproxy.go b/oauthproxy.go index cf4ca1f7..b3717e34 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -48,6 +48,7 @@ var SignatureHeaders = []string{ "Authorization", "X-Forwarded-User", "X-Forwarded-Email", + "X-Forwarded-Preferred-User", "X-Forwarded-Access-Token", "Cookie", "Gap-Auth", @@ -352,6 +353,13 @@ func (p *OAuthProxy) redeemCode(host, code string) (s *sessionsapi.SessionState, s.Email, err = p.provider.GetEmailAddress(s) } + if s.PreferredUsername == "" { + s.PreferredUsername, err = p.provider.GetPreferredUsername(s) + if err != nil && err.Error() == "not implemented" { + err = nil + } + } + if s.User == "" { s.User, err = p.provider.GetUserName(s) if err != nil && err.Error() == "not implemented" { @@ -670,7 +678,7 @@ func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) { } } -//UserInfo endpoint outputs session email in JSON format +//UserInfo endpoint outputs session email and preferred username in JSON format func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) { session, err := p.getAuthenticatedSession(rw, req) @@ -679,8 +687,12 @@ func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) { return } userInfo := struct { - Email string `json:"email"` - }{session.Email} + Email string `json:"email"` + PreferredUsername string `json:"preferredUsername,omitempty"` + }{ + Email: session.Email, + PreferredUsername: session.PreferredUsername, + } rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) json.NewEncoder(rw).Encode(userInfo) @@ -939,6 +951,11 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req req.Header.Del("X-Forwarded-Email") } } + if session.PreferredUsername != "" { + req.Header["X-Forwarded-Preferred-Username"] = []string{session.PreferredUsername} + } else { + req.Header.Del("X-Forwarded-Preferred-Username") + } } if p.PassUserHeaders { @@ -948,6 +965,11 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req } else { req.Header.Del("X-Forwarded-Email") } + if session.PreferredUsername != "" { + req.Header["X-Forwarded-Preferred-Username"] = []string{session.PreferredUsername} + } else { + req.Header.Del("X-Forwarded-Preferred-Username") + } } if p.SetXAuthRequest { @@ -957,6 +979,11 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req } else { rw.Header().Del("X-Auth-Request-Email") } + if session.PreferredUsername != "" { + rw.Header().Set("X-Auth-Request-Preferred-Username", session.PreferredUsername) + } else { + rw.Header().Del("X-Auth-Request-Preferred-Username") + } if p.PassAccessToken { if session.AccessToken != "" { @@ -1066,9 +1093,10 @@ func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState } var claims struct { - Subject string `json:"sub"` - Email string `json:"email"` - Verified *bool `json:"email_verified"` + Subject string `json:"sub"` + Email string `json:"email"` + Verified *bool `json:"email_verified"` + PreferredUsername string `json:"preferred_username"` } if err := bearerToken.Claims(&claims); err != nil { @@ -1084,12 +1112,13 @@ func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState } session = &sessionsapi.SessionState{ - AccessToken: rawBearerToken, - IDToken: rawBearerToken, - RefreshToken: "", - ExpiresOn: bearerToken.Expiry, - Email: claims.Email, - User: claims.Email, + AccessToken: rawBearerToken, + IDToken: rawBearerToken, + RefreshToken: "", + ExpiresOn: bearerToken.Expiry, + Email: claims.Email, + User: claims.Email, + PreferredUsername: claims.PreferredUsername, } return session, nil } diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go index 84c0dc90..0a58b34c 100644 --- a/pkg/apis/sessions/session_state.go +++ b/pkg/apis/sessions/session_state.go @@ -12,13 +12,14 @@ import ( // SessionState is used to store information about the currently authenticated user session type SessionState struct { - AccessToken string `json:",omitempty"` - IDToken string `json:",omitempty"` - CreatedAt time.Time `json:"-"` - ExpiresOn time.Time `json:"-"` - RefreshToken string `json:",omitempty"` - Email string `json:",omitempty"` - User string `json:",omitempty"` + AccessToken string `json:",omitempty"` + IDToken string `json:",omitempty"` + CreatedAt time.Time `json:"-"` + ExpiresOn time.Time `json:"-"` + RefreshToken string `json:",omitempty"` + Email string `json:",omitempty"` + User string `json:",omitempty"` + PreferredUsername string `json:",omitempty"` } // SessionStateJSON is used to encode SessionState into JSON without exposing time.Time zero value @@ -46,7 +47,7 @@ func (s *SessionState) Age() time.Duration { // String constructs a summary of the session state func (s *SessionState) String() string { - o := fmt.Sprintf("Session{email:%s user:%s", s.Email, s.User) + o := fmt.Sprintf("Session{email:%s user:%s PreferredUsername:%s", s.Email, s.User, s.PreferredUsername) if s.AccessToken != "" { o += " token:true" } @@ -72,6 +73,7 @@ func (s *SessionState) EncodeSessionState(c *encryption.Cipher) (string, error) // Store only Email and User when cipher is unavailable ss.Email = s.Email ss.User = s.User + ss.PreferredUsername = s.PreferredUsername } else { ss = *s var err error @@ -87,6 +89,12 @@ func (s *SessionState) EncodeSessionState(c *encryption.Cipher) (string, error) return "", err } } + if ss.PreferredUsername != "" { + ss.PreferredUsername, err = c.Encrypt(ss.PreferredUsername) + if err != nil { + return "", err + } + } if ss.AccessToken != "" { ss.AccessToken, err = c.Encrypt(ss.AccessToken) if err != nil { @@ -199,8 +207,9 @@ func DecodeSessionState(v string, c *encryption.Cipher) (*SessionState, error) { if c == nil { // Load only Email and User when cipher is unavailable ss = &SessionState{ - Email: ss.Email, - User: ss.User, + Email: ss.Email, + User: ss.User, + PreferredUsername: ss.PreferredUsername, } } else { // Backward compatibility with using unencrypted Email @@ -217,6 +226,12 @@ func DecodeSessionState(v string, c *encryption.Cipher) (*SessionState, error) { ss.User = decryptedUser } } + if ss.PreferredUsername != "" { + ss.PreferredUsername, err = c.Decrypt(ss.PreferredUsername) + if err != nil { + return nil, err + } + } if ss.AccessToken != "" { ss.AccessToken, err = c.Decrypt(ss.AccessToken) if err != nil { diff --git a/pkg/apis/sessions/session_state_test.go b/pkg/apis/sessions/session_state_test.go index c8ccff10..9707faef 100644 --- a/pkg/apis/sessions/session_state_test.go +++ b/pkg/apis/sessions/session_state_test.go @@ -19,12 +19,13 @@ func TestSessionStateSerialization(t *testing.T) { c2, err := encryption.NewCipher([]byte(altSecret)) assert.Equal(t, nil, err) s := &sessions.SessionState{ - Email: "user@domain.com", - AccessToken: "token1234", - IDToken: "rawtoken1234", - CreatedAt: time.Now(), - ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), - RefreshToken: "refresh4321", + Email: "user@domain.com", + PreferredUsername: "user", + AccessToken: "token1234", + IDToken: "rawtoken1234", + CreatedAt: time.Now(), + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", } encoded, err := s.EncodeSessionState(c) assert.Equal(t, nil, err) @@ -34,6 +35,7 @@ func TestSessionStateSerialization(t *testing.T) { assert.Equal(t, nil, err) assert.Equal(t, "user@domain.com", ss.User) assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, s.AccessToken, ss.AccessToken) assert.Equal(t, s.IDToken, ss.IDToken) assert.Equal(t, s.CreatedAt.Unix(), ss.CreatedAt.Unix()) @@ -46,6 +48,7 @@ func TestSessionStateSerialization(t *testing.T) { assert.Equal(t, nil, err) assert.NotEqual(t, "user@domain.com", ss.User) assert.NotEqual(t, s.Email, ss.Email) + assert.NotEqual(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, s.CreatedAt.Unix(), ss.CreatedAt.Unix()) assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) assert.NotEqual(t, s.AccessToken, ss.AccessToken) @@ -59,12 +62,13 @@ func TestSessionStateSerializationWithUser(t *testing.T) { c2, err := encryption.NewCipher([]byte(altSecret)) assert.Equal(t, nil, err) s := &sessions.SessionState{ - User: "just-user", - Email: "user@domain.com", - AccessToken: "token1234", - CreatedAt: time.Now(), - ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), - RefreshToken: "refresh4321", + User: "just-user", + PreferredUsername: "ju", + Email: "user@domain.com", + AccessToken: "token1234", + CreatedAt: time.Now(), + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", } encoded, err := s.EncodeSessionState(c) assert.Equal(t, nil, err) @@ -74,6 +78,7 @@ func TestSessionStateSerializationWithUser(t *testing.T) { assert.Equal(t, nil, err) assert.Equal(t, s.User, ss.User) assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, s.AccessToken, ss.AccessToken) assert.Equal(t, s.CreatedAt.Unix(), ss.CreatedAt.Unix()) assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) @@ -85,6 +90,7 @@ func TestSessionStateSerializationWithUser(t *testing.T) { assert.Equal(t, nil, err) assert.NotEqual(t, s.User, ss.User) assert.NotEqual(t, s.Email, ss.Email) + assert.NotEqual(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, s.CreatedAt.Unix(), ss.CreatedAt.Unix()) assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) assert.NotEqual(t, s.AccessToken, ss.AccessToken) @@ -93,11 +99,12 @@ func TestSessionStateSerializationWithUser(t *testing.T) { func TestSessionStateSerializationNoCipher(t *testing.T) { s := &sessions.SessionState{ - Email: "user@domain.com", - AccessToken: "token1234", - CreatedAt: time.Now(), - ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), - RefreshToken: "refresh4321", + Email: "user@domain.com", + PreferredUsername: "user", + AccessToken: "token1234", + CreatedAt: time.Now(), + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", } encoded, err := s.EncodeSessionState(nil) assert.Equal(t, nil, err) @@ -107,18 +114,20 @@ func TestSessionStateSerializationNoCipher(t *testing.T) { assert.Equal(t, nil, err) assert.Equal(t, "user@domain.com", ss.User) assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, "", ss.AccessToken) assert.Equal(t, "", ss.RefreshToken) } func TestSessionStateSerializationNoCipherWithUser(t *testing.T) { s := &sessions.SessionState{ - User: "just-user", - Email: "user@domain.com", - AccessToken: "token1234", - CreatedAt: time.Now(), - ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), - RefreshToken: "refresh4321", + User: "just-user", + Email: "user@domain.com", + PreferredUsername: "user", + AccessToken: "token1234", + CreatedAt: time.Now(), + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", } encoded, err := s.EncodeSessionState(nil) assert.Equal(t, nil, err) @@ -128,6 +137,7 @@ func TestSessionStateSerializationNoCipherWithUser(t *testing.T) { assert.Equal(t, nil, err) assert.Equal(t, s.User, ss.User) assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.PreferredUsername, ss.PreferredUsername) assert.Equal(t, "", ss.AccessToken) assert.Equal(t, "", ss.RefreshToken) } diff --git a/providers/oidc.go b/providers/oidc.go index 4a994017..d687b425 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -121,6 +121,7 @@ func (p *OIDCProvider) redeemRefreshToken(s *sessions.SessionState) (err error) s.IDToken = newSession.IDToken s.Email = newSession.Email s.User = newSession.User + s.PreferredUsername = newSession.PreferredUsername } s.AccessToken = newSession.AccessToken @@ -165,6 +166,7 @@ func (p *OIDCProvider) createSessionState(token *oauth2.Token, idToken *oidc.IDT newSession.IDToken = token.Extra("id_token").(string) newSession.Email = claims.Email newSession.User = claims.Subject + newSession.PreferredUsername = claims.PreferredUsername } } @@ -233,7 +235,8 @@ func findClaimsFromIDToken(idToken *oidc.IDToken, accessToken string, profileURL } type OIDCClaims struct { - Subject string `json:"sub"` - Email string `json:"email"` - Verified *bool `json:"email_verified"` + Subject string `json:"sub"` + Email string `json:"email"` + Verified *bool `json:"email_verified"` + PreferredUsername string `json:"preferred_username"` } diff --git a/providers/provider_default.go b/providers/provider_default.go index 707bbcea..6197f799 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -119,6 +119,11 @@ func (p *ProviderData) GetUserName(s *sessions.SessionState) (string, error) { return "", errors.New("not implemented") } +// GetPreferredUsername returns the Account preferred username +func (p *ProviderData) GetPreferredUsername(s *sessions.SessionState) (string, error) { + return "", errors.New("not implemented") +} + // ValidateGroup validates that the provided email exists in the configured provider // email group(s). func (p *ProviderData) ValidateGroup(email string) bool { diff --git a/providers/providers.go b/providers/providers.go index fb8cccad..04215f01 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -10,6 +10,7 @@ type Provider interface { Data() *ProviderData GetEmailAddress(*sessions.SessionState) (string, error) GetUserName(*sessions.SessionState) (string, error) + GetPreferredUsername(*sessions.SessionState) (string, error) Redeem(string, string) (*sessions.SessionState, error) ValidateGroup(string) bool ValidateSessionState(*sessions.SessionState) bool