Add preferred_username support (OIDC provider) (#420)

* Add support for preferred username.

* Add missing TOC entries.

* Add note about preferred_username support.

* Adjust tests.

* Check on not implemented error for GetPreferredUsername() call.

Co-authored-by: Felix Fontein <felix@fontein.de>
Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk>
This commit is contained in:
Felix Fontein 2020-03-01 16:02:51 +01:00 committed by GitHub
parent 0bca3564b5
commit d934309b44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 118 additions and 51 deletions

View File

@ -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:

View File

@ -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 |

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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