From 5d95123dcb47c0edc8049df99d9028230e06377b Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Tue, 28 Apr 2026 04:25:52 +1200 Subject: [PATCH] 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 --- frontend/src/lib/i18n/locales/en.json | 6 +- frontend/src/pages/authorize-page.tsx | 14 +- .../000009_oidc_userinfo_profile.down.sql | 13 ++ .../000009_oidc_userinfo_profile.up.sql | 13 ++ internal/bootstrap/app_bootstrap.go | 2 +- internal/config/config.go | 48 ++++- internal/controller/user_controller.go | 37 +++- internal/controller/user_controller_test.go | 118 +++++++++-- internal/controller/well_known_controller.go | 2 +- .../controller/well_known_controller_test.go | 2 +- internal/middleware/context_middleware.go | 21 +- internal/repository/models.go | 13 ++ internal/repository/oidc_queries.sql.go | 73 ++++++- internal/service/oidc_service.go | 88 +++++++- internal/service/oidc_service_test.go | 198 ++++++++++++++++++ internal/utils/user_utils.go | 9 +- internal/utils/user_utils_test.go | 99 +++++---- sql/oidc_queries.sql | 17 +- sql/oidc_schemas.sql | 25 ++- 19 files changed, 687 insertions(+), 111 deletions(-) create mode 100644 internal/assets/migrations/000009_oidc_userinfo_profile.down.sql create mode 100644 internal/assets/migrations/000009_oidc_userinfo_profile.up.sql create mode 100644 internal/service/oidc_service_test.go diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 0a5a76c..dd39a6c 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -80,5 +80,9 @@ "profileScopeDescription": "Allows the app to access your profile information.", "groupsScopeName": "Groups", "groupsScopeDescription": "Allows the app to access your group information.", - "backToLoginButton": "Back to login" + "backToLoginButton": "Back to login", + "phoneScopeName": "Phone", + "phoneScopeDescription": "Allows the app to access your phone number.", + "addressScopeName": "Address", + "addressScopeDescription": "Allows the app to access your address." } diff --git a/frontend/src/pages/authorize-page.tsx b/frontend/src/pages/authorize-page.tsx index 24357d2..f2b7c11 100644 --- a/frontend/src/pages/authorize-page.tsx +++ b/frontend/src/pages/authorize-page.tsx @@ -17,7 +17,7 @@ import { toast } from "sonner"; import { useOIDCParams } from "@/lib/hooks/oidc"; import { useTranslation } from "react-i18next"; import { TFunction } from "i18next"; -import { Mail, Shield, User, Users } from "lucide-react"; +import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react"; import { Tooltip, TooltipContent, @@ -61,6 +61,18 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => { description: t("groupsScopeDescription"), icon: , }, + { + id: "phone", + name: t("phoneScopeName"), + description: t("phoneScopeDescription"), + icon: , + }, + { + id: "address", + name: t("addressScopeName"), + description: t("addressScopeDescription"), + icon: , + }, ]; }; diff --git a/internal/assets/migrations/000009_oidc_userinfo_profile.down.sql b/internal/assets/migrations/000009_oidc_userinfo_profile.down.sql new file mode 100644 index 0000000..0baa9cf --- /dev/null +++ b/internal/assets/migrations/000009_oidc_userinfo_profile.down.sql @@ -0,0 +1,13 @@ +ALTER TABLE "oidc_userinfo" DROP COLUMN "given_name"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "family_name"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "middle_name"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "nickname"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "profile"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "picture"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "website"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "gender"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "birthdate"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "zoneinfo"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "locale"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "phone_number"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "address"; diff --git a/internal/assets/migrations/000009_oidc_userinfo_profile.up.sql b/internal/assets/migrations/000009_oidc_userinfo_profile.up.sql new file mode 100644 index 0000000..f91e964 --- /dev/null +++ b/internal/assets/migrations/000009_oidc_userinfo_profile.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE "oidc_userinfo" ADD COLUMN "given_name" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "family_name" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "middle_name" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "nickname" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "profile" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "picture" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "website" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "gender" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "zoneinfo" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "locale" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "phone_number" TEXT NOT NULL DEFAULT ""; +ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}"; diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index dfb7e75..3879c05 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -63,7 +63,7 @@ func (app *BootstrapApp) Setup() error { } // Parse users - users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile) + users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes) if err != nil { return err diff --git a/internal/config/config.go b/internal/config/config.go index b8db08a..1bf64af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,15 +113,43 @@ type ServerConfig struct { } type AuthConfig struct { - IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` - Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` - UsersFile string `description:"Path to the users file." yaml:"usersFile"` - SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` - SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` - SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` - LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` - LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` - TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` + IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` + Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` + UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"` + UsersFile string `description:"Path to the users file." yaml:"usersFile"` + SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` + SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` + SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` + LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` + LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` + TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` +} + +type UserAttributes struct { + Name string `description:"Full name of the user." yaml:"name"` + GivenName string `description:"Given (first) name of the user." yaml:"givenName"` + FamilyName string `description:"Family (last) name of the user." yaml:"familyName"` + MiddleName string `description:"Middle name of the user." yaml:"middleName"` + Nickname string `description:"Nickname of the user." yaml:"nickname"` + Profile string `description:"URL of the user's profile page." yaml:"profile"` + Picture string `description:"URL of the user's profile picture." yaml:"picture"` + Website string `description:"URL of the user's website." yaml:"website"` + Email string `description:"Email address of the user." yaml:"email"` + Gender string `description:"Gender of the user." yaml:"gender"` + Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"` + Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"` + Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"` + PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"` + Address AddressClaim `description:"Address of the user." yaml:"address"` +} + +type AddressClaim struct { + Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"` + StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"` + Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"` + Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"` + PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"` + Country string `description:"Country." yaml:"country" json:"country,omitempty"` } type IPConfig struct { @@ -228,6 +256,7 @@ type User struct { Username string Password string TotpSecret string + Attributes UserAttributes } type LdapUser struct { @@ -254,6 +283,7 @@ type UserContext struct { OAuthName string OAuthSub string LdapGroups string + Attributes UserAttributes } // API responses and queries diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 20f1184..187b33b 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/tinyauthapp/tinyauth/internal/config" "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils" @@ -105,16 +106,32 @@ func (controller *UserController) loginHandler(c *gin.Context) { controller.auth.RecordLoginAttempt(req.Username, true) + var localUser *config.User if userSearch.Type == "local" { user := controller.auth.GetLocalUser(userSearch.Username) + localUser = &user + } + + if userSearch.Type == "local" && localUser != nil { + user := *localUser if user.TotpSecret != "" { tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") + name := user.Attributes.Name + if name == "" { + name = utils.Capitalize(user.Username) + } + + email := user.Attributes.Email + if email == "" { + email = utils.CompileUserEmail(user.Username, controller.config.CookieDomain) + } + err := controller.auth.CreateSessionCookie(c, &repository.Session{ Username: user.Username, - Name: utils.Capitalize(user.Username), - Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain), + Name: name, + Email: email, Provider: "local", TotpPending: true, }) @@ -144,6 +161,15 @@ func (controller *UserController) loginHandler(c *gin.Context) { Provider: "local", } + if userSearch.Type == "local" && localUser != nil { + if localUser.Attributes.Name != "" { + sessionCookie.Name = localUser.Attributes.Name + } + if localUser.Attributes.Email != "" { + sessionCookie.Email = localUser.Attributes.Email + } + } + if userSearch.Type == "ldap" { sessionCookie.Provider = "ldap" } @@ -258,6 +284,13 @@ func (controller *UserController) totpHandler(c *gin.Context) { Provider: "local", } + if user.Attributes.Name != "" { + sessionCookie.Name = user.Attributes.Name + } + if user.Attributes.Email != "" { + sessionCookie.Email = user.Attributes.Email + } + tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") err = controller.auth.CreateSessionCookie(c, &sessionCookie) diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index 1643aec..d7a0773 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http/httptest" "path" - "slices" "strings" "testing" "time" @@ -36,6 +35,23 @@ func TestUserController(t *testing.T) { Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", }, + { + Username: "attruser", + Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password + Attributes: config.UserAttributes{ + Name: "Alice Smith", + Email: "alice@example.com", + }, + }, + { + Username: "attrtotpuser", + Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password + TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", + Attributes: config.UserAttributes{ + Name: "Bob Jones", + Email: "bob@example.com", + }, + }, }, SessionExpiry: 10, // 10 seconds, useful for testing CookieDomain: "example.com", @@ -273,6 +289,64 @@ func TestUserController(t *testing.T) { assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.") }, }, + { + description: "Login uses name and email from user attributes", + middlewares: []gin.HandlerFunc{}, + run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { + loginReq := controller.LoginRequest{Username: "attruser", Password: "password"} + body, err := json.Marshal(loginReq) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + require.Equal(t, 200, recorder.Code) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "tinyauth-session", cookies[0].Name) + }, + }, + { + description: "Login with TOTP uses name and email from user attributes in pending session", + middlewares: []gin.HandlerFunc{}, + run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { + loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"} + body, err := json.Marshal(loginReq) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + require.Equal(t, 200, recorder.Code) + var res map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res)) + assert.Equal(t, true, res["totpPending"]) + require.Len(t, recorder.Result().Cookies(), 1) + }, + }, + { + description: "TOTP completion uses name and email from user attributes", + middlewares: []gin.HandlerFunc{}, + run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { + code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) + require.NoError(t, err) + + totpReq := controller.TotpRequest{Code: code} + body, err := json.Marshal(totpReq) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + require.Equal(t, 200, recorder.Code) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "tinyauth-session", cookies[0].Name) + }, + }, } oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig) @@ -305,9 +379,31 @@ func TestUserController(t *testing.T) { authService.ClearRateLimitsTestingOnly() } - setTotpMiddlewareOverrides := []string{ - "Should be able to login with totp", - "Totp should rate limit on multiple invalid attempts", + setTotpMiddlewareOverrides := map[string]config.UserContext{ + "Should be able to login with totp": { + Username: "totpuser", + Name: "Totpuser", + Email: "totpuser@example.com", + Provider: "local", + TotpPending: true, + TotpEnabled: true, + }, + "Totp should rate limit on multiple invalid attempts": { + Username: "totpuser", + Name: "Totpuser", + Email: "totpuser@example.com", + Provider: "local", + TotpPending: true, + TotpEnabled: true, + }, + "TOTP completion uses name and email from user attributes": { + Username: "attrtotpuser", + Name: "Bob Jones", + Email: "bob@example.com", + Provider: "local", + TotpPending: true, + TotpEnabled: true, + }, } for _, test := range tests { @@ -321,18 +417,10 @@ func TestUserController(t *testing.T) { // Gin is stupid and doesn't allow setting a middleware after the groups // so we need to do some stupid overrides here - if slices.Contains(setTotpMiddlewareOverrides, test.description) { - // Assuming the cookie is set, it should be picked up by the - // context middleware + if ctx, ok := setTotpMiddlewareOverrides[test.description]; ok { + ctx := ctx router.Use(func(c *gin.Context) { - c.Set("context", &config.UserContext{ - Username: "totpuser", - Name: "Totpuser", - Email: "totpuser@example.com", - Provider: "local", - TotpPending: true, - TotpEnabled: true, - }) + c.Set("context", &ctx) }) } diff --git a/internal/controller/well_known_controller.go b/internal/controller/well_known_controller.go index b94bd93..f31a9ed 100644 --- a/internal/controller/well_known_controller.go +++ b/internal/controller/well_known_controller.go @@ -61,7 +61,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context SubjectTypesSupported: []string{"pairwise"}, IDTokenSigningAlgValuesSupported: []string{"RS256"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, - ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"}, + ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", RequestParameterSupported: true, RequestObjectSigningAlgValuesSupported: []string{"none"}, diff --git a/internal/controller/well_known_controller_test.go b/internal/controller/well_known_controller_test.go index ac1f369..7d8d05f 100644 --- a/internal/controller/well_known_controller_test.go +++ b/internal/controller/well_known_controller_test.go @@ -67,7 +67,7 @@ func TestWellKnownController(t *testing.T) { SubjectTypesSupported: []string{"pairwise"}, IDTokenSigningAlgValuesSupported: []string{"RS256"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, - ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"}, + ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", RequestParameterSupported: true, RequestObjectSigningAlgValuesSupported: []string{"none"}, diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 36e6a94..651d9d8 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -99,6 +99,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } var ldapGroups []string + var localAttributes config.UserAttributes if cookie.Provider == "ldap" { ldapUser, err := m.auth.GetLdapUser(userSearch.Username) @@ -112,6 +113,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { ldapGroups = ldapUser.Groups } + if cookie.Provider == "local" { + localUser := m.auth.GetLocalUser(cookie.Username) + localAttributes = localUser.Attributes + } + m.auth.RefreshSessionCookie(c) c.Set("context", &config.UserContext{ Username: cookie.Username, @@ -120,6 +126,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { Provider: cookie.Provider, IsLoggedIn: true, LdapGroups: strings.Join(ldapGroups, ","), + Attributes: localAttributes, }) c.Next() return @@ -202,13 +209,23 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { return } + name := utils.Capitalize(user.Username) + if user.Attributes.Name != "" { + name = user.Attributes.Name + } + email := utils.CompileUserEmail(user.Username, m.config.CookieDomain) + if user.Attributes.Email != "" { + email = user.Attributes.Email + } + c.Set("context", &config.UserContext{ Username: user.Username, - Name: utils.Capitalize(user.Username), - Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain), + Name: name, + Email: email, Provider: "local", IsLoggedIn: true, IsBasicAuth: true, + Attributes: user.Attributes, }) c.Next() return diff --git a/internal/repository/models.go b/internal/repository/models.go index f08dd51..bc2e2c6 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -34,6 +34,19 @@ type OidcUserinfo struct { Email string Groups string UpdatedAt int64 + GivenName string + FamilyName string + MiddleName string + Nickname string + Profile string + Picture string + Website string + Gender string + Birthdate string + Zoneinfo string + Locale string + PhoneNumber string + Address string } type Session struct { diff --git a/internal/repository/oidc_queries.sql.go b/internal/repository/oidc_queries.sql.go index 8ca6893..7caac9d 100644 --- a/internal/repository/oidc_queries.sql.go +++ b/internal/repository/oidc_queries.sql.go @@ -124,11 +124,24 @@ INSERT INTO "oidc_userinfo" ( "preferred_username", "email", "groups", - "updated_at" + "updated_at", + "given_name", + "family_name", + "middle_name", + "nickname", + "profile", + "picture", + "website", + "gender", + "birthdate", + "zoneinfo", + "locale", + "phone_number", + "address" ) VALUES ( - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) -RETURNING sub, name, preferred_username, email, "groups", updated_at +RETURNING sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address ` type CreateOidcUserInfoParams struct { @@ -138,6 +151,19 @@ type CreateOidcUserInfoParams struct { Email string Groups string UpdatedAt int64 + GivenName string + FamilyName string + MiddleName string + Nickname string + Profile string + Picture string + Website string + Gender string + Birthdate string + Zoneinfo string + Locale string + PhoneNumber string + Address string } func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) { @@ -148,6 +174,19 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo arg.Email, arg.Groups, arg.UpdatedAt, + arg.GivenName, + arg.FamilyName, + arg.MiddleName, + arg.Nickname, + arg.Profile, + arg.Picture, + arg.Website, + arg.Gender, + arg.Birthdate, + arg.Zoneinfo, + arg.Locale, + arg.PhoneNumber, + arg.Address, ) var i OidcUserinfo err := row.Scan( @@ -157,6 +196,19 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo &i.Email, &i.Groups, &i.UpdatedAt, + &i.GivenName, + &i.FamilyName, + &i.MiddleName, + &i.Nickname, + &i.Profile, + &i.Picture, + &i.Website, + &i.Gender, + &i.Birthdate, + &i.Zoneinfo, + &i.Locale, + &i.PhoneNumber, + &i.Address, ) return i, err } @@ -456,7 +508,7 @@ func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, } const getOidcUserInfo = `-- name: GetOidcUserInfo :one -SELECT sub, name, preferred_username, email, "groups", updated_at FROM "oidc_userinfo" +SELECT sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo" WHERE "sub" = ? ` @@ -470,6 +522,19 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo &i.Email, &i.Groups, &i.UpdatedAt, + &i.GivenName, + &i.FamilyName, + &i.MiddleName, + &i.Nickname, + &i.Profile, + &i.Picture, + &i.Website, + &i.Gender, + &i.Birthdate, + &i.Zoneinfo, + &i.Locale, + &i.PhoneNumber, + &i.Address, ) return i, err } diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 06bd489..888ad0e 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -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 } diff --git a/internal/service/oidc_service_test.go b/internal/service/oidc_service_test.go new file mode 100644 index 0000000..222ad62 --- /dev/null +++ b/internal/service/oidc_service_test.go @@ -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) + }) + } +} diff --git a/internal/utils/user_utils.go b/internal/utils/user_utils.go index c37dee3..d80c655 100644 --- a/internal/utils/user_utils.go +++ b/internal/utils/user_utils.go @@ -9,7 +9,7 @@ import ( "github.com/tinyauthapp/tinyauth/internal/config" ) -func ParseUsers(usersStr []string) ([]config.User, error) { +func ParseUsers(usersStr []string, userAttributes map[string]config.UserAttributes) ([]config.User, error) { var users []config.User if len(usersStr) == 0 { @@ -24,13 +24,16 @@ func ParseUsers(usersStr []string) ([]config.User, error) { if err != nil { return []config.User{}, err } + if attrs, ok := userAttributes[parsed.Username]; ok { + parsed.Attributes = attrs + } users = append(users, parsed) } return users, nil } -func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) { +func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]config.UserAttributes) ([]config.User, error) { var usersStr []string if len(usersCfg) == 0 && usersPath == "" { @@ -59,7 +62,7 @@ func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) { } } - return ParseUsers(usersStr) + return ParseUsers(usersStr, userAttributes) } func ParseUser(userStr string) (config.User, error) { diff --git a/internal/utils/user_utils_test.go b/internal/utils/user_utils_test.go index e95ae77..dcbb75c 100644 --- a/internal/utils/user_utils_test.go +++ b/internal/utils/user_utils_test.go @@ -4,122 +4,117 @@ import ( "os" "testing" + "github.com/tinyauthapp/tinyauth/internal/config" "github.com/tinyauthapp/tinyauth/internal/utils" "gotest.tools/v3/assert" ) func TestGetUsers(t *testing.T) { + hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G" + // Setup file, err := os.Create("/tmp/tinyauth_users_test.txt") assert.NilError(t, err) - _, err = file.WriteString(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G \n user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ") // Spacing is on purpose + _, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose assert.NilError(t, err) err = file.Close() assert.NilError(t, err) defer os.Remove("/tmp/tinyauth_users_test.txt") - // Test file - users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt") + noAttrs := map[string]config.UserAttributes{} + + // Test file only + users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", noAttrs) assert.NilError(t, err) assert.Equal(t, 2, len(users)) assert.Equal(t, "user1", users[0].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, hash, users[0].Password) assert.Equal(t, "user2", users[1].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + assert.Equal(t, hash, users[1].Password) - // Test config - users, err = utils.GetUsers([]string{"user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "") + // Test inline config only + users, err = utils.GetUsers([]string{"user3:" + hash, "user4:" + hash}, "", noAttrs) assert.NilError(t, err) assert.Equal(t, 2, len(users)) - assert.Equal(t, "user3", users[0].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) assert.Equal(t, "user4", users[1].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) // Test both - users, err = utils.GetUsers([]string{"user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "/tmp/tinyauth_users_test.txt") + users, err = utils.GetUsers([]string{"user5:" + hash}, "/tmp/tinyauth_users_test.txt", noAttrs) assert.NilError(t, err) assert.Equal(t, 3, len(users)) - assert.Equal(t, "user5", users[0].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) - assert.Equal(t, "user1", users[1].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) - assert.Equal(t, "user2", users[2].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password) + usernames := map[string]bool{} + for _, u := range users { + usernames[u.Username] = true + } + assert.Assert(t, usernames["user1"]) + assert.Assert(t, usernames["user2"]) + assert.Assert(t, usernames["user5"]) + + // Test attributes applied from userAttributes map + attrs := map[string]config.UserAttributes{ + "user1": {Name: "User One", Email: "user1@example.com"}, + } + users, err = utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt", attrs) + + assert.NilError(t, err) + assert.Equal(t, 2, len(users)) + + for _, u := range users { + if u.Username == "user1" { + assert.Equal(t, "User One", u.Attributes.Name) + assert.Equal(t, "user1@example.com", u.Attributes.Email) + } + if u.Username == "user2" { + assert.Equal(t, "", u.Attributes.Name) + } + } // Test empty - users, err = utils.GetUsers([]string{}, "") + users, err = utils.GetUsers([]string{}, "", noAttrs) assert.NilError(t, err) assert.Equal(t, 0, len(users)) // Test non-existent file - users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt") + users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt", noAttrs) assert.ErrorContains(t, err, "no such file or directory") assert.Equal(t, 0, len(users)) } -func TestParseUsers(t *testing.T) { - // Valid users - users, err := utils.ParseUsers([]string{"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF"}) // user2 has TOTP - - assert.NilError(t, err) - - assert.Equal(t, 2, len(users)) - - assert.Equal(t, "user1", users[0].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) - assert.Equal(t, "", users[0].TotpSecret) - assert.Equal(t, "user2", users[1].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) - assert.Equal(t, "ABCDEF", users[1].TotpSecret) - - // Valid weirdly spaced users - users, err = utils.ParseUsers([]string{" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ", " user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF "}) // Spacing is on purpose - assert.NilError(t, err) - - assert.Equal(t, 2, len(users)) - - assert.Equal(t, "user1", users[0].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) - assert.Equal(t, "", users[0].TotpSecret) - assert.Equal(t, "user2", users[1].Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) - assert.Equal(t, "ABCDEF", users[1].TotpSecret) -} - func TestParseUser(t *testing.T) { + hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G" + // Valid user without TOTP - user, err := utils.ParseUser("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G") + user, err := utils.ParseUser("user1:" + hash) assert.NilError(t, err) assert.Equal(t, "user1", user.Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) + assert.Equal(t, hash, user.Password) assert.Equal(t, "", user.TotpSecret) // Valid user with TOTP - user, err = utils.ParseUser("user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") + user, err = utils.ParseUser("user2:" + hash + ":ABCDEF") assert.NilError(t, err) assert.Equal(t, "user2", user.Username) - assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) + assert.Equal(t, hash, user.Password) assert.Equal(t, "ABCDEF", user.TotpSecret) // Valid user with $$ in password diff --git a/sql/oidc_queries.sql b/sql/oidc_queries.sql index 4ceba2c..67b7b95 100644 --- a/sql/oidc_queries.sql +++ b/sql/oidc_queries.sql @@ -95,9 +95,22 @@ INSERT INTO "oidc_userinfo" ( "preferred_username", "email", "groups", - "updated_at" + "updated_at", + "given_name", + "family_name", + "middle_name", + "nickname", + "profile", + "picture", + "website", + "gender", + "birthdate", + "zoneinfo", + "locale", + "phone_number", + "address" ) VALUES ( - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) RETURNING *; diff --git a/sql/oidc_schemas.sql b/sql/oidc_schemas.sql index e570d12..d9a7ba4 100644 --- a/sql/oidc_schemas.sql +++ b/sql/oidc_schemas.sql @@ -22,10 +22,23 @@ CREATE TABLE IF NOT EXISTS "oidc_tokens" ( ); CREATE TABLE IF NOT EXISTS "oidc_userinfo" ( - "sub" TEXT NOT NULL UNIQUE PRIMARY KEY, - "name" TEXT NOT NULL, - "preferred_username" TEXT NOT NULL, - "email" TEXT NOT NULL, - "groups" TEXT NOT NULL, - "updated_at" INTEGER NOT NULL + "sub" TEXT NOT NULL UNIQUE PRIMARY KEY, + "name" TEXT NOT NULL, + "preferred_username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "groups" TEXT NOT NULL, + "updated_at" INTEGER NOT NULL, + "given_name" TEXT NOT NULL, + "family_name" TEXT NOT NULL, + "middle_name" TEXT NOT NULL, + "nickname" TEXT NOT NULL, + "profile" TEXT NOT NULL, + "picture" TEXT NOT NULL, + "website" TEXT NOT NULL, + "gender" TEXT NOT NULL, + "birthdate" TEXT NOT NULL, + "zoneinfo" TEXT NOT NULL, + "locale" TEXT NOT NULL, + "phone_number" TEXT NOT NULL, + "address" TEXT NOT NULL );