mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-04-29 17:08:13 +00:00
feat(oidc): support for all in-spec attributes and scopes (#777)
* feat(oidc): support for all in-spec attributes and scopes * add tests * assert phone/email verified when either is set * update tests * add claims back to userinfo * remove redundant column drop in migration * fix duplicate migration id * fix clobbered imports post-rebase
This commit is contained in:
@@ -28,7 +28,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
SupportedScopes = []string{"openid", "profile", "email", "groups"}
|
||||
SupportedScopes = []string{"openid", "profile", "email", "phone", "address", "groups"}
|
||||
SupportedResponseTypes = []string{"code"}
|
||||
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
||||
)
|
||||
@@ -48,6 +48,17 @@ type ClaimSet struct {
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Birthdate string `json:"birthdate,omitempty"`
|
||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
@@ -56,13 +67,27 @@ type ClaimSet struct {
|
||||
}
|
||||
|
||||
type UserinfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Birthdate string `json:"birthdate,omitempty"`
|
||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
|
||||
Address *config.AddressClaim `json:"address,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
@@ -342,12 +367,30 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
||||
}
|
||||
|
||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
|
||||
addressJSON, err := json.Marshal(userContext.Attributes.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userInfoParams := repository.CreateOidcUserInfoParams{
|
||||
Sub: sub,
|
||||
Name: userContext.Name,
|
||||
Email: userContext.Email,
|
||||
PreferredUsername: userContext.Username,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
GivenName: userContext.Attributes.GivenName,
|
||||
FamilyName: userContext.Attributes.FamilyName,
|
||||
MiddleName: userContext.Attributes.MiddleName,
|
||||
Nickname: userContext.Attributes.Nickname,
|
||||
Profile: userContext.Attributes.Profile,
|
||||
Picture: userContext.Attributes.Picture,
|
||||
Website: userContext.Attributes.Website,
|
||||
Gender: userContext.Attributes.Gender,
|
||||
Birthdate: userContext.Attributes.Birthdate,
|
||||
Zoneinfo: userContext.Attributes.Zoneinfo,
|
||||
Locale: userContext.Attributes.Locale,
|
||||
PhoneNumber: userContext.Attributes.PhoneNumber,
|
||||
Address: string(addressJSON),
|
||||
}
|
||||
|
||||
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
||||
@@ -359,7 +402,7 @@ func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContex
|
||||
userInfoParams.Groups = userContext.OAuthGroups
|
||||
}
|
||||
|
||||
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||
_, err = service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -637,12 +680,22 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
if slices.Contains(scopes, "profile") {
|
||||
userInfo.Name = user.Name
|
||||
userInfo.PreferredUsername = user.PreferredUsername
|
||||
userInfo.GivenName = user.GivenName
|
||||
userInfo.FamilyName = user.FamilyName
|
||||
userInfo.MiddleName = user.MiddleName
|
||||
userInfo.Nickname = user.Nickname
|
||||
userInfo.Profile = user.Profile
|
||||
userInfo.Picture = user.Picture
|
||||
userInfo.Website = user.Website
|
||||
userInfo.Gender = user.Gender
|
||||
userInfo.Birthdate = user.Birthdate
|
||||
userInfo.Zoneinfo = user.Zoneinfo
|
||||
userInfo.Locale = user.Locale
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "email") {
|
||||
userInfo.Email = user.Email
|
||||
// We can set this as a configuration option in the future but for now it's a good idea to assume it's true
|
||||
userInfo.EmailVerified = true
|
||||
userInfo.EmailVerified = user.Email != ""
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "groups") {
|
||||
@@ -653,6 +706,19 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "phone") {
|
||||
userInfo.PhoneNumber = user.PhoneNumber
|
||||
verified := user.PhoneNumber != ""
|
||||
userInfo.PhoneNumberVerified = &verified
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "address") {
|
||||
var addr config.AddressClaim
|
||||
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
|
||||
userInfo.Address = &addr
|
||||
}
|
||||
}
|
||||
|
||||
return userInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
)
|
||||
|
||||
func newTestUser() repository.OidcUserinfo {
|
||||
addr := config.AddressClaim{
|
||||
Formatted: "123 Main St",
|
||||
StreetAddress: "123 Main St",
|
||||
Locality: "Springfield",
|
||||
Region: "IL",
|
||||
PostalCode: "62701",
|
||||
Country: "US",
|
||||
}
|
||||
addrJSON, _ := json.Marshal(addr)
|
||||
|
||||
return repository.OidcUserinfo{
|
||||
Sub: "test-sub",
|
||||
Name: "Test User",
|
||||
PreferredUsername: "testuser",
|
||||
Email: "test@example.com",
|
||||
Groups: "admins,users",
|
||||
UpdatedAt: 1234567890,
|
||||
GivenName: "Test",
|
||||
FamilyName: "User",
|
||||
MiddleName: "M",
|
||||
Nickname: "testy",
|
||||
Profile: "https://example.com/testuser",
|
||||
Picture: "https://example.com/testuser.jpg",
|
||||
Website: "https://testuser.example.com",
|
||||
Gender: "male",
|
||||
Birthdate: "1990-01-01",
|
||||
Zoneinfo: "America/Chicago",
|
||||
Locale: "en-US",
|
||||
PhoneNumber: "+15555550100",
|
||||
Address: string(addrJSON),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileUserinfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := service.NewOIDCService(service.OIDCServiceConfig{
|
||||
PrivateKeyPath: dir + "/key.pem",
|
||||
PublicKeyPath: dir + "/key.pub",
|
||||
Issuer: "https://tinyauth.example.com",
|
||||
SessionExpiry: 3600,
|
||||
}, nil)
|
||||
require.NoError(t, svc.Init())
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
mutate func(u *repository.OidcUserinfo)
|
||||
scope string
|
||||
run func(t *testing.T, info service.UserinfoResponse)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "openid scope only returns sub and updated_at",
|
||||
scope: "openid",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "test-sub", info.Sub)
|
||||
assert.Equal(t, int64(1234567890), info.UpdatedAt)
|
||||
assert.Empty(t, info.Name)
|
||||
assert.Empty(t, info.Email)
|
||||
assert.Nil(t, info.Groups)
|
||||
assert.Nil(t, info.PhoneNumberVerified)
|
||||
assert.Nil(t, info.Address)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "profile scope returns all profile fields",
|
||||
scope: "openid,profile",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "testuser", info.PreferredUsername)
|
||||
assert.Equal(t, "Test", info.GivenName)
|
||||
assert.Equal(t, "User", info.FamilyName)
|
||||
assert.Equal(t, "M", info.MiddleName)
|
||||
assert.Equal(t, "testy", info.Nickname)
|
||||
assert.Equal(t, "https://example.com/testuser", info.Profile)
|
||||
assert.Equal(t, "https://example.com/testuser.jpg", info.Picture)
|
||||
assert.Equal(t, "https://testuser.example.com", info.Website)
|
||||
assert.Equal(t, "male", info.Gender)
|
||||
assert.Equal(t, "1990-01-01", info.Birthdate)
|
||||
assert.Equal(t, "America/Chicago", info.Zoneinfo)
|
||||
assert.Equal(t, "en-US", info.Locale)
|
||||
assert.Empty(t, info.Email)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "email scope sets email and email_verified true when email present",
|
||||
scope: "openid,email",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.True(t, info.EmailVerified)
|
||||
assert.Empty(t, info.Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "email scope sets email_verified false when email absent",
|
||||
scope: "openid,email",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Email = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Empty(t, info.Email)
|
||||
assert.False(t, info.EmailVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified true when phone present",
|
||||
scope: "openid,phone",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.True(t, *info.PhoneNumberVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified false when phone absent",
|
||||
scope: "openid,phone",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.PhoneNumber = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.False(t, *info.PhoneNumberVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope returns parsed address",
|
||||
scope: "openid,address",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
require.NotNil(t, info.Address)
|
||||
assert.Equal(t, "123 Main St", info.Address.Formatted)
|
||||
assert.Equal(t, "123 Main St", info.Address.StreetAddress)
|
||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||
assert.Equal(t, "IL", info.Address.Region)
|
||||
assert.Equal(t, "62701", info.Address.PostalCode)
|
||||
assert.Equal(t, "US", info.Address.Country)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope with invalid JSON omits address",
|
||||
scope: "openid,address",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Address = "not-valid-json" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Nil(t, info.Address)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns split groups",
|
||||
scope: "openid,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns empty slice when no groups",
|
||||
scope: "openid,groups",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Groups = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, []string{}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "all scopes return all fields",
|
||||
scope: "openid,profile,email,phone,address,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.True(t, *info.PhoneNumberVerified)
|
||||
require.NotNil(t, info.Address)
|
||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
user := newTestUser()
|
||||
if test.mutate != nil {
|
||||
test.mutate(&user)
|
||||
}
|
||||
info := svc.CompileUserinfo(user, test.scope)
|
||||
test.run(t, info)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user