fix(oidc): add preferred username claim (#2801)

This adds the missing preferred username claim to the ID Token for OIDC.

Fixes #2798
This commit is contained in:
James Elliott 2022-01-18 20:32:06 +11:00 committed by GitHub
parent e4391892b5
commit 06641cd15a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 218 additions and 83 deletions

View File

@ -19,8 +19,8 @@ providers for authentication and authorization. We do not intend to support this
## Roadmap ## Roadmap
We have decided to implement [OpenID Connect] as a beta feature, it's suggested you only utilize it for testing and We have decided to implement [OpenID Connect] as a beta feature, it's suggested you only utilize it for testing and
providing feedback, and should take caution in relying on it in production as of now. [OpenID Connect] and it's related endpoints providing feedback, and should take caution in relying on it in production as of now. [OpenID Connect] and it's related
are not enabled by default unless you specifically configure the [OpenID Connect] section. endpoints are not enabled by default unless you specifically configure the [OpenID Connect] section.
As [OpenID Connect] is fairly complex (the [OpenID Connect] Provider role especially so) it's intentional that it is As [OpenID Connect] is fairly complex (the [OpenID Connect] Provider role especially so) it's intentional that it is
both a beta and that the implemented features are part of a thoughtful roadmap. Items that are not immediately obvious both a beta and that the implemented features are part of a thoughtful roadmap. Items that are not immediately obvious
@ -489,48 +489,52 @@ characters. For Kubernetes, see [this section too](../secrets.md#Kubernetes).
### openid ### openid
This is the default scope for openid. This field is forced on every client by the configuration This is the default scope for openid. This field is forced on every client by the configuration validation that Authelia
validation that Authelia does. does.
|JWT Field|JWT Type |Authelia Attribute|Description | _**Important Note:** The claim `sub` is planned to be changed in the future to a randomly unique value to identify the
|:-------:|:-----------:|:----------------:|:-------------------------------------------:| individual user. Please use the claim `preferred_username` instead._
|sub |string |Username |The username the user used to login with |
|scope |string |scopes |Granted scopes (space delimited) | | JWT Field | JWT Type | Authelia Attribute | Description |
|scp |array[string]|scopes |Granted scopes | |:------------------:|:-------------:|:------------------:|:---------------------------------------------:|
|iss |string |hostname |The issuer name, determined by URL | | sub | string | Username | The username the user used to login with |
|at_hash |string |_N/A_ |Access Token Hash | | scope | string | scopes | Granted scopes (space delimited) |
|aud |array[string]|_N/A_ |Audience | | scp | array[string] | scopes | Granted scopes |
|exp |number |_N/A_ |Expires | | iss | string | hostname | The issuer name, determined by URL |
|auth_time|number |_N/A_ |The time the user authenticated with Authelia| | at_hash | string | _N/A_ | Access Token Hash |
|rat |number |_N/A_ |The time when the token was requested | | aud | array[string] | _N/A_ | Audience |
|iat |number |_N/A_ |The time when the token was issued | | exp | number | _N/A_ | Expires |
|jti |string(uuid) |_N/A_ |JWT Identifier | | auth_time | number | _N/A_ | The time the user authenticated with Authelia |
| rat | number | _N/A_ | The time when the token was requested |
| iat | number | _N/A_ | The time when the token was issued |
| jti | string(uuid) | _N/A_ | JWT Identifier |
| preferred_username | string | Username | The username the user used to login with |
### groups ### groups
This scope includes the groups the authentication backend reports the user is a member of in the token. This scope includes the groups the authentication backend reports the user is a member of in the token.
|JWT Field|JWT Type |Authelia Attribute|Description | | JWT Field | JWT Type | Authelia Attribute | Description |
|:-------:|:-----------:|:----------------:|:--------------------:| |:---------:|:-------------:|:------------------:|:----------------------:|
|groups |array[string]|Groups |The users display name| | groups | array[string] | Groups | The users display name |
### email ### email
This scope includes the email information the authentication backend reports about the user in the token. This scope includes the email information the authentication backend reports about the user in the token.
|JWT Field |JWT Type |Authelia Attribute|Description | | JWT Field | JWT Type | Authelia Attribute | Description |
|:------------:|:-----------:|:----------------:|:-------------------------------------------------------:| |:--------------:|:-------------:|:------------------:|:---------------------------------------------------------:|
|email |string |email[0] |The first email address in the list of emails | | email | string | email[0] | The first email address in the list of emails |
|email_verified|bool |_N/A_ |If the email is verified, assumed true for the time being| | email_verified | bool | _N/A_ | If the email is verified, assumed true for the time being |
|alt_emails |array[string]|email[1:] |All email addresses that are not in the email JWT field | | alt_emails | array[string] | email[1:] | All email addresses that are not in the email JWT field |
### profile ### profile
This scope includes the profile information the authentication backend reports about the user in the token. This scope includes the profile information the authentication backend reports about the user in the token.
|JWT Field|JWT Type|Authelia Attribute|Description | | JWT Field | JWT Type | Authelia Attribute | Description |
|:-------:|:------:|:----------------:|:--------------------:| |:---------:|:--------:|:------------------:|:----------------------:|
|name |string | display_name |The users display name| | name | string | display_name | The users display name |
## Endpoint Implementations ## Endpoint Implementations
@ -539,15 +543,15 @@ particularly those that don't use [discovery](https://openid.net/specs/openid-co
appended to the end of the primary URL used to access Authelia. For example in the Discovery example provided you access appended to the end of the primary URL used to access Authelia. For example in the Discovery example provided you access
Authelia via https://auth.example.com, the discovery URL is https://auth.example.com/.well-known/openid-configuration. Authelia via https://auth.example.com, the discovery URL is https://auth.example.com/.well-known/openid-configuration.
|Endpoint |Path | | Endpoint | Path |
|:-----------:|:------------------------------:| |:-------------:|:--------------------------------:|
|Discovery |.well-known/openid-configuration| | Discovery | .well-known/openid-configuration |
|JWKS |api/oidc/jwks | | JWKS | api/oidc/jwks |
|Authorization|api/oidc/authorize | | Authorization | api/oidc/authorize |
|Token |api/oidc/token | | Token | api/oidc/token |
|Introspection|api/oidc/introspect | | Introspection | api/oidc/introspect |
|Revocation |api/oidc/revoke | | Revocation | api/oidc/revoke |
|Userinfo |api/oidc/userinfo | | Userinfo | api/oidc/userinfo |
[OpenID Connect]: https://openid.net/connect/ [OpenID Connect]: https://openid.net/connect/
[token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration [token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration

View File

@ -14,7 +14,6 @@ import (
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/oidc"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/utils"
) )
func oidcAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http.Request) { func oidcAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *http.Request) {
@ -93,7 +92,8 @@ func oidcAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *
Headers: &jwt.Headers{Extra: map[string]interface{}{ Headers: &jwt.Headers{Extra: map[string]interface{}{
"kid": ctx.Providers.OpenIDConnect.KeyManager.GetActiveKeyID(), "kid": ctx.Providers.OpenIDConnect.KeyManager.GetActiveKeyID(),
}}, }},
Subject: userSession.Username, Subject: userSession.Username,
Username: userSession.Username,
}, },
ClientID: clientID, ClientID: clientID,
}) })
@ -107,40 +107,6 @@ func oidcAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r *
ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeResponse(rw, ar, response) ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeResponse(rw, ar, response)
} }
func oidcGrantRequests(ar fosite.AuthorizeRequester, scopes, audiences []string, userSession *session.UserSession) (extraClaims map[string]interface{}) {
extraClaims = map[string]interface{}{}
for _, scope := range scopes {
ar.GrantScope(scope)
switch scope {
case "groups":
extraClaims["groups"] = userSession.Groups
case "profile":
extraClaims["name"] = userSession.DisplayName
case "email":
if len(userSession.Emails) != 0 {
extraClaims["email"] = userSession.Emails[0]
if len(userSession.Emails) > 1 {
extraClaims["alt_emails"] = userSession.Emails[1:]
}
// TODO (james-d-elliott): actually verify emails and record that information.
extraClaims["email_verified"] = true
}
}
}
for _, audience := range audiences {
ar.GrantAudience(audience)
}
if !utils.IsStringInSlice(ar.GetClient().GetID(), ar.GetGrantedAudience()) {
ar.GrantAudience(ar.GetClient().GetID())
}
return extraClaims
}
func oidcAuthorizeHandleAuthorizationOrConsentInsufficient( func oidcAuthorizeHandleAuthorizationOrConsentInsufficient(
ctx *middlewares.AutheliaCtx, userSession session.UserSession, client *oidc.InternalClient, isAuthInsufficient bool, ctx *middlewares.AutheliaCtx, userSession session.UserSession, client *oidc.InternalClient, isAuthInsufficient bool,
rw http.ResponseWriter, r *http.Request, rw http.ResponseWriter, r *http.Request,

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
@ -30,3 +31,43 @@ func newOpenIDSession(subject string) *oidc.OpenIDSession {
Extra: map[string]interface{}{}, Extra: map[string]interface{}{},
} }
} }
func oidcGrantRequests(ar fosite.AuthorizeRequester, scopes, audiences []string, userSession *session.UserSession) (extraClaims map[string]interface{}) {
extraClaims = map[string]interface{}{
oidc.ClaimPreferredUsername: userSession.Username,
}
for _, scope := range scopes {
if ar != nil {
ar.GrantScope(scope)
}
switch scope {
case oidc.ScopeGroups:
extraClaims[oidc.ClaimGroups] = userSession.Groups
case oidc.ScopeProfile:
extraClaims[oidc.ClaimDisplayName] = userSession.DisplayName
case oidc.ScopeEmail:
if len(userSession.Emails) != 0 {
extraClaims[oidc.ClaimEmail] = userSession.Emails[0]
if len(userSession.Emails) > 1 {
extraClaims[oidc.ClaimAltEmails] = userSession.Emails[1:]
}
// TODO (james-d-elliott): actually verify emails and record that information.
extraClaims[oidc.ClaimEmailVerified] = true
}
}
}
if ar != nil {
for _, audience := range audiences {
ar.GrantAudience(audience)
}
if !utils.IsStringInSlice(ar.GetClient().GetID(), ar.GetGrantedAudience()) {
ar.GrantAudience(ar.GetClient().GetID())
}
}
return extraClaims
}

View File

@ -4,7 +4,9 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/v4/internal/oidc"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
) )
@ -31,3 +33,107 @@ func TestShouldDetectIfConsentIsMissing(t *testing.T) {
requestedAudience = []string{"https://not.authelia.com"} requestedAudience = []string{"https://not.authelia.com"}
assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience)) assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience))
} }
func TestShouldGrantAppropriateClaimsForScopeOpenID(t *testing.T) {
extraClaims := oidcGrantRequests(nil, []string{oidc.ScopeOpenID}, []string{}, &oidcUserSessionJohn)
assert.Len(t, extraClaims, 1)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername])
}
func TestShouldGrantAppropriateClaimsForScopeOpenIDAndGroups(t *testing.T) {
extraClaims := oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeGroups}, []string{}, &oidcUserSessionJohn)
assert.Len(t, extraClaims, 2)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimGroups)
assert.Len(t, extraClaims[oidc.ClaimGroups], 2)
assert.Contains(t, extraClaims[oidc.ClaimGroups], "admin")
assert.Contains(t, extraClaims[oidc.ClaimGroups], "dev")
extraClaims = oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeGroups}, []string{}, &oidcUserSessionFred)
assert.Len(t, extraClaims, 2)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "fred", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimGroups)
assert.Len(t, extraClaims[oidc.ClaimGroups], 1)
assert.Contains(t, extraClaims[oidc.ClaimGroups], "dev")
}
func TestShouldGrantAppropriateClaimsForScopeOpenIDAndEmail(t *testing.T) {
extraClaims := oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeEmail}, []string{}, &oidcUserSessionJohn)
assert.Len(t, extraClaims, 4)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimEmail)
assert.Equal(t, "j.smith@authelia.com", extraClaims[oidc.ClaimEmail])
require.Contains(t, extraClaims, oidc.ClaimAltEmails)
assert.Len(t, extraClaims[oidc.ClaimAltEmails], 1)
assert.Contains(t, extraClaims[oidc.ClaimAltEmails], "admin@authelia.com")
require.Contains(t, extraClaims, oidc.ClaimEmailVerified)
assert.Equal(t, true, extraClaims[oidc.ClaimEmailVerified])
extraClaims = oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeEmail}, []string{}, &oidcUserSessionFred)
assert.Len(t, extraClaims, 3)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "fred", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimEmail)
assert.Equal(t, "f.smith@authelia.com", extraClaims[oidc.ClaimEmail])
require.Contains(t, extraClaims, oidc.ClaimEmailVerified)
assert.Equal(t, true, extraClaims[oidc.ClaimEmailVerified])
}
func TestShouldGrantAppropriateClaimsForScopeOpenIDAndProfile(t *testing.T) {
extraClaims := oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeProfile}, []string{}, &oidcUserSessionJohn)
assert.Len(t, extraClaims, 2)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "john", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimDisplayName)
assert.Equal(t, "John Smith", extraClaims[oidc.ClaimDisplayName])
extraClaims = oidcGrantRequests(nil, []string{oidc.ScopeOpenID, oidc.ScopeProfile}, []string{}, &oidcUserSessionFred)
assert.Len(t, extraClaims, 2)
require.Contains(t, extraClaims, oidc.ClaimPreferredUsername)
assert.Equal(t, "fred", extraClaims[oidc.ClaimPreferredUsername])
require.Contains(t, extraClaims, oidc.ClaimDisplayName)
assert.Equal(t, extraClaims[oidc.ClaimDisplayName], "Fred Smith")
}
var (
oidcUserSessionJohn = session.UserSession{
Username: "john",
Groups: []string{"admin", "dev"},
DisplayName: "John Smith",
Emails: []string{"j.smith@authelia.com", "admin@authelia.com"},
}
oidcUserSessionFred = session.UserSession{
Username: "fred",
Groups: []string{"dev"},
DisplayName: "Fred Smith",
Emails: []string{"f.smith@authelia.com"},
}
)

View File

@ -8,3 +8,21 @@ var scopeDescriptions = map[string]string{
} }
var audienceDescriptions = map[string]string{} var audienceDescriptions = map[string]string{}
// Scope strings.
const (
ScopeOpenID = "openid"
ScopeProfile = "profile"
ScopeEmail = "email"
ScopeGroups = "groups"
)
// Claim strings.
const (
ClaimGroups = "groups"
ClaimDisplayName = "name"
ClaimPreferredUsername = "preferred_username"
ClaimEmail = "email"
ClaimEmailVerified = "email_verified"
ClaimAltEmails = "alt_emails"
)