diff --git a/authority/provisioner/oidc.go b/authority/provisioner/oidc.go index 787de317..3b2d81bf 100644 --- a/authority/provisioner/oidc.go +++ b/authority/provisioner/oidc.go @@ -388,7 +388,7 @@ func (o *OIDC) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption } // Get the identity using either the default identityFunc or one injected - // externally. + // externally. Note that the PreferredUsername might be empty. iden, err := o.getIdentityFunc(ctx, o, claims.Email, claims.PreferredUsername) if err != nil { return nil, errs.Wrap(http.StatusInternalServerError, err, "oidc.AuthorizeSSHSign") diff --git a/authority/provisioner/oidc_test.go b/authority/provisioner/oidc_test.go index d203516c..85c6b1a9 100644 --- a/authority/provisioner/oidc_test.go +++ b/authority/provisioner/oidc_test.go @@ -514,8 +514,10 @@ func TestOIDC_AuthorizeSSHSign(t *testing.T) { assert.FatalError(t, err) failGetIdentityToken, err := generateSimpleToken("the-issuer", p5.ClientID, &keys.Keys[0]) assert.FatalError(t, err) + okPreferredUsername, err := generateOIDCToken("subject", "the-issuer", p1.ClientID, "name@smallstep.com", "lecris", time.Now(), &keys.Keys[0]) + assert.FatalError(t, err) // Admin email not in domains - okAdmin, err := generateToken("subject", "the-issuer", p3.ClientID, "root@example.com", []string{}, time.Now(), &keys.Keys[0]) + okAdmin, err := generateOIDCToken("subject", "the-issuer", p3.ClientID, "root@example.com", "", time.Now(), &keys.Keys[0]) assert.FatalError(t, err) // Empty email failEmail, err := generateToken("subject", "the-issuer", p3.ClientID, "", []string{}, time.Now(), &keys.Keys[0]) @@ -574,14 +576,17 @@ func TestOIDC_AuthorizeSSHSign(t *testing.T) { {"ok-emptyPrincipals-getIdentity", p4, args{okGetIdentityToken, SignSSHOptions{}, pub}, &SignSSHOptions{CertType: "user", Principals: []string{"max", "mariano"}, ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false}, + {"ok-preferred-username", p1, args{okPreferredUsername, SignSSHOptions{CertType: "user", KeyID: "name@smallstep.com", Principals: []string{"lecris"}}, pub}, + &SignSSHOptions{CertType: "user", Principals: []string{"lecris", "name", "name@smallstep.com"}, + ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false}, {"ok-options", p1, args{t1, SignSSHOptions{CertType: "user", Principals: []string{"name"}}, pub}, &SignSSHOptions{CertType: "user", Principals: []string{"name", "name@smallstep.com"}, ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false}, - {"admin-user", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "root@example.com", Principals: []string{"root", "root@example.com"}}, pub}, + {"ok-admin-user", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "root@example.com", Principals: []string{"root", "root@example.com"}}, pub}, expectedAdminOptions, http.StatusOK, false, false}, - {"admin-host", p3, args{okAdmin, SignSSHOptions{CertType: "host", KeyID: "smallstep.com", Principals: []string{"smallstep.com"}}, pub}, + {"ok-admin-host", p3, args{okAdmin, SignSSHOptions{CertType: "host", KeyID: "smallstep.com", Principals: []string{"smallstep.com"}}, pub}, expectedHostOptions, http.StatusOK, false, false}, - {"admin-options", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "name", Principals: []string{"name"}}, pub}, + {"ok-admin-options", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "name", Principals: []string{"name"}}, pub}, &SignSSHOptions{CertType: "user", Principals: []string{"name"}, ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false}, {"fail-rsa1024", p1, args{t1, SignSSHOptions{}, rsa1024.Public()}, expectedUserOptions, http.StatusOK, false, true}, diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 30bf5d47..1ea48069 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -339,34 +339,35 @@ type Permissions struct { // GetIdentityFunc is a function that returns an identity. type GetIdentityFunc func(ctx context.Context, p Interface, email string, usernames ...string) (*Identity, error) -// DefaultIdentityFunc return a default identity depending on the provisioner type. +// DefaultIdentityFunc return a default identity depending on the provisioner +// type. For OIDC email is always present and the usernames might +// contain empty strings. func DefaultIdentityFunc(ctx context.Context, p Interface, email string, usernames ...string) (*Identity, error) { switch k := p.(type) { case *OIDC: // OIDC principals would be: - // 1. Sanitized local. - // 2. Raw local (if different). - // 3. Email address. + // 1. Preferred usernames. + // 2. Sanitized local. + // 3. Raw local (if different). + // 4. Email address. name := SanitizeSSHUserPrincipal(email) if !sshUserRegex.MatchString(name) { return nil, errors.Errorf("invalid principal '%s' from email '%s'", name, email) } usernames := append(usernames, name) if i := strings.LastIndex(email, "@"); i >= 0 { - if local := email[:i]; !strings.EqualFold(local, name) { - usernames = append(usernames, local) - } + usernames = append(usernames, email[:i]) } usernames = append(usernames, email) - usernames = SanitizeStringSlices(usernames) return &Identity{ - Usernames: usernames, + Usernames: SanitizeStringSlices(usernames), }, nil default: return nil, errors.Errorf("provisioner type '%T' not supported by identity function", k) } } +// SanitizeStringSlices removes duplicated an empty strings. func SanitizeStringSlices(original []string) []string { output := []string{} seen := make(map[string]bool) diff --git a/authority/provisioner/provisioner_test.go b/authority/provisioner/provisioner_test.go index bf99aa76..f1cf2ff5 100644 --- a/authority/provisioner/provisioner_test.go +++ b/authority/provisioner/provisioner_test.go @@ -62,10 +62,11 @@ func TestSanitizeSSHUserPrincipal(t *testing.T) { func TestDefaultIdentityFunc(t *testing.T) { type test struct { - p Interface - email string - err error - identity *Identity + p Interface + email string + usernames []string + err error + identity *Identity } tests := map[string]func(*testing.T) test{ "fail/unsupported-provisioner": func(t *testing.T) test { @@ -106,7 +107,7 @@ func TestDefaultIdentityFunc(t *testing.T) { return test{ p: &OIDC{}, email: "John@smallstep.com", - identity: &Identity{Usernames: []string{"john", "John@smallstep.com"}}, + identity: &Identity{Usernames: []string{"john", "John", "John@smallstep.com"}}, } }, "ok symbol": func(t *testing.T) test { @@ -116,11 +117,35 @@ func TestDefaultIdentityFunc(t *testing.T) { identity: &Identity{Usernames: []string{"john_doe", "John+Doe", "John+Doe@smallstep.com"}}, } }, + "ok username": func(t *testing.T) test { + return test{ + p: &OIDC{}, + email: "john@smallstep.com", + usernames: []string{"johnny"}, + identity: &Identity{Usernames: []string{"johnny", "john", "john@smallstep.com"}}, + } + }, + "ok usernames": func(t *testing.T) test { + return test{ + p: &OIDC{}, + email: "john@smallstep.com", + usernames: []string{"johnny", "js", ""}, + identity: &Identity{Usernames: []string{"johnny", "js", "john", "john@smallstep.com"}}, + } + }, + "ok empty username": func(t *testing.T) test { + return test{ + p: &OIDC{}, + email: "john@smallstep.com", + usernames: []string{""}, + identity: &Identity{Usernames: []string{"john", "john@smallstep.com"}}, + } + }, } for name, get := range tests { t.Run(name, func(t *testing.T) { tc := get(t) - identity, err := DefaultIdentityFunc(context.Background(), tc.p, tc.email) + identity, err := DefaultIdentityFunc(context.Background(), tc.p, tc.email, tc.usernames...) if err != nil { if assert.NotNil(t, tc.err) { assert.Equals(t, tc.err.Error(), err.Error()) diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index fb0eb9e7..534e83cf 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -773,6 +773,47 @@ func generateToken(sub, iss, aud string, email string, sans []string, iat time.T return jose.Signed(sig).Claims(claims).CompactSerialize() } +func generateOIDCToken(sub, iss, aud string, email string, preferredUsername string, iat time.Time, jwk *jose.JSONWebKey, tokOpts ...tokOption) (string, error) { + so := new(jose.SignerOptions) + so.WithType("JWT") + so.WithHeader("kid", jwk.KeyID) + + for _, o := range tokOpts { + if err := o(so); err != nil { + return "", err + } + } + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, so) + if err != nil { + return "", err + } + + id, err := randutil.ASCII(64) + if err != nil { + return "", err + } + + claims := struct { + jose.Claims + Email string `json:"email"` + PreferredUsername string `json:"preferred_username,omitempty"` + }{ + Claims: jose.Claims{ + ID: id, + Subject: sub, + Issuer: iss, + IssuedAt: jose.NewNumericDate(iat), + NotBefore: jose.NewNumericDate(iat), + Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)), + Audience: []string{aud}, + }, + Email: email, + PreferredUsername: preferredUsername, + } + return jose.Signed(sig).Claims(claims).CompactSerialize() +} + func generateX5CSSHToken(jwk *jose.JSONWebKey, claims *x5cPayload, tokOpts ...tokOption) (string, error) { so := new(jose.SignerOptions) so.WithType("JWT")