From 0330e176e0396d5b6eeb66fc47a2904731cc534b Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Thu, 9 Apr 2026 21:09:23 +1200 Subject: [PATCH 1/8] feat(oidc): support for all in-spec attributes and scopes --- frontend/src/lib/i18n/locales/en.json | 6 +- frontend/src/pages/authorize-page.tsx | 14 ++- .../000008_oidc_userinfo_profile.down.sql | 14 +++ .../000008_oidc_userinfo_profile.up.sql | 14 +++ internal/bootstrap/app_bootstrap.go | 2 +- internal/config/config.go | 49 ++++++-- internal/controller/user_controller.go | 36 +++++- internal/controller/well_known_controller.go | 2 +- .../controller/well_known_controller_test.go | 2 +- internal/middleware/context_middleware.go | 21 +++- internal/repository/models.go | 26 +++- internal/repository/oidc_queries.sql.go | 90 +++++++++++-- internal/service/oidc_service.go | 119 ++++++++++++++---- internal/utils/user_utils.go | 9 +- internal/utils/user_utils_test.go | 98 +++++++-------- sql/oidc_queries.sql | 18 ++- sql/oidc_schemas.sql | 26 +++- 17 files changed, 424 insertions(+), 122 deletions(-) create mode 100644 internal/assets/migrations/000008_oidc_userinfo_profile.down.sql create mode 100644 internal/assets/migrations/000008_oidc_userinfo_profile.up.sql diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 0a5a76c8..dd39a6ce 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 24357d22..f2b7c11d 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/000008_oidc_userinfo_profile.down.sql b/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql new file mode 100644 index 00000000..861eeb77 --- /dev/null +++ b/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql @@ -0,0 +1,14 @@ +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 "phone_number_verified"; +ALTER TABLE "oidc_userinfo" DROP COLUMN "address"; diff --git a/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql b/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql new file mode 100644 index 00000000..cda85825 --- /dev/null +++ b/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql @@ -0,0 +1,14 @@ +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 "phone_number_verified" INTEGER NOT NULL DEFAULT 0; +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 dfb7e75b..3879c05e 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 b8db08a9..25972861 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,15 +113,44 @@ 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"` + PhoneNumberVerified bool `description:"Whether the phone number has been verified." yaml:"phoneNumberVerified"` + 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 +257,7 @@ type User struct { Username string Password string TotpSecret string + Attributes UserAttributes } type LdapUser struct { @@ -254,6 +284,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 20f1184d..89e0d0c1 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -105,16 +105,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 +160,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 +283,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/well_known_controller.go b/internal/controller/well_known_controller.go index b94bd933..f31a9ed7 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 ac1f369d..7d8d05f5 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 36e6a945..651d9d85 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 f08dd512..905e1294 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -28,12 +28,26 @@ type OidcToken struct { } type OidcUserinfo struct { - Sub string - Name string - PreferredUsername string - Email string - Groups string - UpdatedAt int64 + Sub string + Name string + PreferredUsername string + 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 + PhoneNumberVerified int64 + Address string } type Session struct { diff --git a/internal/repository/oidc_queries.sql.go b/internal/repository/oidc_queries.sql.go index 8ca6893b..fc5cb73f 100644 --- a/internal/repository/oidc_queries.sql.go +++ b/internal/repository/oidc_queries.sql.go @@ -124,20 +124,48 @@ 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", + "phone_number_verified", + "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, phone_number_verified, address ` type CreateOidcUserInfoParams struct { - Sub string - Name string - PreferredUsername string - Email string - Groups string - UpdatedAt int64 + Sub string + Name string + PreferredUsername string + 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 + PhoneNumberVerified int64 + Address string } func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) { @@ -148,6 +176,20 @@ 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.PhoneNumberVerified, + arg.Address, ) var i OidcUserinfo err := row.Scan( @@ -157,6 +199,20 @@ 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.PhoneNumberVerified, + &i.Address, ) return i, err } @@ -456,7 +512,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, phone_number_verified, address FROM "oidc_userinfo" WHERE "sub" = ? ` @@ -470,6 +526,20 @@ 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.PhoneNumberVerified, + &i.Address, ) return i, err } diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 06bd4892..68caa282 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,36 @@ 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, + PhoneNumberVerified: func() int64 { + if userContext.Attributes.PhoneNumberVerified { + return 1 + } + return 0 + }(), + Address: string(addressJSON), } // Tinyauth will pass through the groups it got from an LDAP or an OIDC server @@ -359,7 +408,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 } @@ -401,7 +450,7 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client return oidcCode, nil } -func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) { +func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, nonce string) (string, error) { createdAt := time.Now().Unix() expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix() @@ -430,20 +479,16 @@ func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user return "", err } - userInfo := service.CompileUserinfo(user, scope) - + // Per OIDC Core §5.4: for code flow, scope-requested claims (profile, email) + // belong in the userinfo response only. The id_token carries only the required + // JWT claims (iss, aud, sub, iat, exp) plus nonce. claims := ClaimSet{ - Iss: service.issuer, - Aud: client.ClientID, - Sub: user.Sub, - Iat: createdAt, - Exp: expiresAt, - Name: userInfo.Name, - Email: userInfo.Email, - EmailVerified: userInfo.EmailVerified, - PreferredUsername: userInfo.PreferredUsername, - Groups: userInfo.Groups, - Nonce: nonce, + Iss: service.issuer, + Aud: client.ClientID, + Sub: user.Sub, + Iat: createdAt, + Exp: expiresAt, + Nonce: nonce, } payload, err := json.Marshal(claims) @@ -474,7 +519,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OI return TokenResponse{}, err } - idToken, err := service.generateIDToken(client, user, codeEntry.Scope, codeEntry.Nonce) + idToken, err := service.generateIDToken(client, user, codeEntry.Nonce) if err != nil { return TokenResponse{}, err @@ -543,7 +588,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri idToken, err := service.generateIDToken(config.OIDCClientConfig{ ClientID: entry.ClientID, - }, user, entry.Scope, entry.Nonce) + }, user, entry.Nonce) if err != nil { return TokenResponse{}, err @@ -637,6 +682,17 @@ 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") { @@ -653,6 +709,19 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope } } + if slices.Contains(scopes, "phone") { + userInfo.PhoneNumber = user.PhoneNumber + verified := user.PhoneNumberVerified != 0 + 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/utils/user_utils.go b/internal/utils/user_utils.go index c37dee36..d80c655d 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 e95ae778..e2b1c15b 100644 --- a/internal/utils/user_utils_test.go +++ b/internal/utils/user_utils_test.go @@ -10,116 +10,110 @@ import ( ) 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 4ceba2c0..3ebf1317 100644 --- a/sql/oidc_queries.sql +++ b/sql/oidc_queries.sql @@ -95,9 +95,23 @@ 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", + "phone_number_verified", + "address" ) VALUES ( - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) RETURNING *; diff --git a/sql/oidc_schemas.sql b/sql/oidc_schemas.sql index e570d127..a6027bb8 100644 --- a/sql/oidc_schemas.sql +++ b/sql/oidc_schemas.sql @@ -22,10 +22,24 @@ 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, + "phone_number_verified" INTEGER NOT NULL, + "address" TEXT NOT NULL ); From 9328ab398bb652c1683d5bebef20a4a77e90ad7e Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Sat, 11 Apr 2026 18:20:51 +1200 Subject: [PATCH 2/8] add tests --- internal/controller/user_controller_test.go | 147 +++++++++++++++ internal/service/oidc_service_test.go | 194 ++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 internal/service/oidc_service_test.go diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index 1643aecb..4664eb3f 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -353,3 +353,150 @@ func TestUserController(t *testing.T) { require.NoError(t, err) }) } + +func TestUserControllerAttributes(t *testing.T) { + tlog.NewTestLogger().Init() + tempDir := t.TempDir() + + authServiceCfg := service.AuthServiceConfig{ + Users: []config.User{ + { + 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, + CookieDomain: "example.com", + LoginTimeout: 10, + LoginMaxRetries: 3, + SessionCookieName: "tinyauth-session", + } + + userControllerCfg := controller.UserControllerConfig{ + CookieDomain: "example.com", + } + + app := bootstrap.NewBootstrapApp(config.Config{}) + db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth_attrs.db")) + require.NoError(t, err) + + queries := repository.New(db) + + docker := service.NewDockerService() + err = docker.Init() + require.NoError(t, err) + + ldap := service.NewLdapService(service.LdapServiceConfig{}) + err = ldap.Init() + require.NoError(t, err) + + broker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig)) + err = broker.Init() + require.NoError(t, err) + + authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker) + err = authService.Init() + require.NoError(t, err) + + makeRouter := func(extraMiddlewares ...gin.HandlerFunc) *gin.Engine { + router := gin.Default() + for _, m := range extraMiddlewares { + router.Use(m) + } + gin.SetMode(gin.TestMode) + group := router.Group("/api") + ctrl := controller.NewUserController(userControllerCfg, group, authService) + ctrl.SetupRoutes() + return router + } + + t.Run("Login uses name and email from user attributes", func(t *testing.T) { + authService.ClearRateLimitsTestingOnly() + router := makeRouter() + + 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") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, 200, rec.Code) + cookies := rec.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "tinyauth-session", cookies[0].Name) + }) + + t.Run("Login with TOTP uses name and email from user attributes in pending session", func(t *testing.T) { + authService.ClearRateLimitsTestingOnly() + router := makeRouter() + + 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") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, 200, rec.Code) + var res map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) + assert.Equal(t, true, res["totpPending"]) + require.Len(t, rec.Result().Cookies(), 1) + }) + + t.Run("TOTP completion uses name and email from user attributes", func(t *testing.T) { + authService.ClearRateLimitsTestingOnly() + + // First: login to get TOTP-pending session + router := makeRouter(func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "attrtotpuser", + Name: "Bob Jones", + Email: "bob@example.com", + Provider: "local", + TotpPending: true, + TotpEnabled: true, + }) + }) + + 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") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, 200, rec.Code) + cookies := rec.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "tinyauth-session", cookies[0].Name) + }) + + t.Cleanup(func() { + err = db.Close() + require.NoError(t, err) + }) +} diff --git a/internal/service/oidc_service_test.go b/internal/service/oidc_service_test.go new file mode 100644 index 00000000..1cb79f23 --- /dev/null +++ b/internal/service/oidc_service_test.go @@ -0,0 +1,194 @@ +package service_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/steveiliop56/tinyauth/internal/config" + "github.com/steveiliop56/tinyauth/internal/repository" + "github.com/steveiliop56/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", + PhoneNumberVerified: 1, + Address: string(addrJSON), + } +} + +func newOIDCService(t *testing.T) *service.OIDCService { + t.Helper() + 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()) + return svc +} + +func TestCompileUserinfo_OpenidOnly(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid") + + assert.Equal(t, "test-sub", info.Sub) + assert.Equal(t, int64(1234567890), info.UpdatedAt) + // profile fields not requested + 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) +} + +func TestCompileUserinfo_ProfileScope(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,profile") + + 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) + // non-profile fields still absent + assert.Empty(t, info.Email) +} + +func TestCompileUserinfo_EmailScope(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,email") + + assert.Equal(t, "test@example.com", info.Email) + assert.True(t, info.EmailVerified) + assert.Empty(t, info.Name) // profile not requested +} + +func TestCompileUserinfo_PhoneScope(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,phone") + + assert.Equal(t, "+15555550100", info.PhoneNumber) + require.NotNil(t, info.PhoneNumberVerified) + assert.True(t, *info.PhoneNumberVerified) +} + +func TestCompileUserinfo_PhoneScope_Unverified(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + user.PhoneNumberVerified = 0 + + info := svc.CompileUserinfo(user, "openid,phone") + + require.NotNil(t, info.PhoneNumberVerified) + assert.False(t, *info.PhoneNumberVerified) +} + +func TestCompileUserinfo_AddressScope(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,address") + + 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) +} + +func TestCompileUserinfo_AddressScope_InvalidJSON(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + user.Address = "not-valid-json" + + info := svc.CompileUserinfo(user, "openid,address") + + // invalid JSON silently skipped, address omitted + assert.Nil(t, info.Address) +} + +func TestCompileUserinfo_GroupsScope(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,groups") + + assert.Equal(t, []string{"admins", "users"}, info.Groups) +} + +func TestCompileUserinfo_GroupsScope_Empty(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + user.Groups = "" + + info := svc.CompileUserinfo(user, "openid,groups") + + assert.Equal(t, []string{}, info.Groups) +} + +func TestCompileUserinfo_AllScopes(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + + info := svc.CompileUserinfo(user, "openid,profile,email,phone,address,groups") + + 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) +} From f7aa3680be28d1dda64449afa5b626cd5ee4284a Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Sun, 12 Apr 2026 05:17:48 +1200 Subject: [PATCH 3/8] assert phone/email verified when either is set --- .../000008_oidc_userinfo_profile.up.sql | 1 - internal/config/config.go | 31 ++++++----- internal/repository/models.go | 39 +++++++------- internal/repository/oidc_queries.sql.go | 49 ++++++++--------- internal/service/oidc_service.go | 13 ++--- internal/service/oidc_service_test.go | 52 +++++++++++-------- sql/oidc_queries.sql | 3 +- sql/oidc_schemas.sql | 1 - 8 files changed, 91 insertions(+), 98 deletions(-) diff --git a/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql b/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql index cda85825..f91e964a 100644 --- a/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql +++ b/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql @@ -10,5 +10,4 @@ ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL 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 "phone_number_verified" INTEGER NOT NULL DEFAULT 0; ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}"; diff --git a/internal/config/config.go b/internal/config/config.go index 25972861..1bf64af4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,22 +126,21 @@ type AuthConfig struct { } 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"` - PhoneNumberVerified bool `description:"Whether the phone number has been verified." yaml:"phoneNumberVerified"` - Address AddressClaim `description:"Address of the user." yaml:"address"` + 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 { diff --git a/internal/repository/models.go b/internal/repository/models.go index 905e1294..bc2e2c66 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -28,26 +28,25 @@ type OidcToken struct { } type OidcUserinfo struct { - Sub string - Name string - PreferredUsername string - 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 - PhoneNumberVerified int64 - Address string + Sub string + Name string + PreferredUsername string + 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 fc5cb73f..7caac9d4 100644 --- a/internal/repository/oidc_queries.sql.go +++ b/internal/repository/oidc_queries.sql.go @@ -137,35 +137,33 @@ INSERT INTO "oidc_userinfo" ( "zoneinfo", "locale", "phone_number", - "phone_number_verified", "address" ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) -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, phone_number_verified, address +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 { - Sub string - Name string - PreferredUsername string - 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 - PhoneNumberVerified int64 - Address string + Sub string + Name string + PreferredUsername string + 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) { @@ -188,7 +186,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo arg.Zoneinfo, arg.Locale, arg.PhoneNumber, - arg.PhoneNumberVerified, arg.Address, ) var i OidcUserinfo @@ -211,7 +208,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo &i.Zoneinfo, &i.Locale, &i.PhoneNumber, - &i.PhoneNumberVerified, &i.Address, ) return i, err @@ -512,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, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, phone_number_verified, address 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" = ? ` @@ -538,7 +534,6 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo &i.Zoneinfo, &i.Locale, &i.PhoneNumber, - &i.PhoneNumberVerified, &i.Address, ) return i, err diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 68caa282..5344e1cd 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -390,13 +390,7 @@ func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContex Zoneinfo: userContext.Attributes.Zoneinfo, Locale: userContext.Attributes.Locale, PhoneNumber: userContext.Attributes.PhoneNumber, - PhoneNumberVerified: func() int64 { - if userContext.Attributes.PhoneNumberVerified { - return 1 - } - return 0 - }(), - Address: string(addressJSON), + Address: string(addressJSON), } // Tinyauth will pass through the groups it got from an LDAP or an OIDC server @@ -697,8 +691,7 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope 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") { @@ -711,7 +704,7 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope if slices.Contains(scopes, "phone") { userInfo.PhoneNumber = user.PhoneNumber - verified := user.PhoneNumberVerified != 0 + verified := user.PhoneNumber != "" userInfo.PhoneNumberVerified = &verified } diff --git a/internal/service/oidc_service_test.go b/internal/service/oidc_service_test.go index 1cb79f23..c28300bc 100644 --- a/internal/service/oidc_service_test.go +++ b/internal/service/oidc_service_test.go @@ -24,26 +24,25 @@ func newTestUser() repository.OidcUserinfo { 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", - PhoneNumberVerified: 1, - Address: string(addrJSON), + 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), } } @@ -110,6 +109,17 @@ func TestCompileUserinfo_EmailScope(t *testing.T) { assert.Empty(t, info.Name) // profile not requested } +func TestCompileUserinfo_EmailScope_Unverified(t *testing.T) { + svc := newOIDCService(t) + user := newTestUser() + user.Email = "" + + info := svc.CompileUserinfo(user, "openid,email") + + assert.Empty(t, info.Email) + assert.False(t, info.EmailVerified) +} + func TestCompileUserinfo_PhoneScope(t *testing.T) { svc := newOIDCService(t) user := newTestUser() @@ -124,7 +134,7 @@ func TestCompileUserinfo_PhoneScope(t *testing.T) { func TestCompileUserinfo_PhoneScope_Unverified(t *testing.T) { svc := newOIDCService(t) user := newTestUser() - user.PhoneNumberVerified = 0 + user.PhoneNumber = "" info := svc.CompileUserinfo(user, "openid,phone") diff --git a/sql/oidc_queries.sql b/sql/oidc_queries.sql index 3ebf1317..67b7b95e 100644 --- a/sql/oidc_queries.sql +++ b/sql/oidc_queries.sql @@ -108,10 +108,9 @@ INSERT INTO "oidc_userinfo" ( "zoneinfo", "locale", "phone_number", - "phone_number_verified", "address" ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) RETURNING *; diff --git a/sql/oidc_schemas.sql b/sql/oidc_schemas.sql index a6027bb8..d9a7ba4e 100644 --- a/sql/oidc_schemas.sql +++ b/sql/oidc_schemas.sql @@ -40,6 +40,5 @@ CREATE TABLE IF NOT EXISTS "oidc_userinfo" ( "zoneinfo" TEXT NOT NULL, "locale" TEXT NOT NULL, "phone_number" TEXT NOT NULL, - "phone_number_verified" INTEGER NOT NULL, "address" TEXT NOT NULL ); From f5a0d4aa89998668859c26bcd59e1417ffcdebd0 Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Sun, 12 Apr 2026 05:28:35 +1200 Subject: [PATCH 4/8] update tests --- internal/controller/user_controller_test.go | 265 +++++++----------- internal/service/oidc_service_test.go | 282 ++++++++++---------- 2 files changed, 241 insertions(+), 306 deletions(-) diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index 4664eb3f..d7a07732 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) }) } @@ -353,150 +441,3 @@ func TestUserController(t *testing.T) { require.NoError(t, err) }) } - -func TestUserControllerAttributes(t *testing.T) { - tlog.NewTestLogger().Init() - tempDir := t.TempDir() - - authServiceCfg := service.AuthServiceConfig{ - Users: []config.User{ - { - 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, - CookieDomain: "example.com", - LoginTimeout: 10, - LoginMaxRetries: 3, - SessionCookieName: "tinyauth-session", - } - - userControllerCfg := controller.UserControllerConfig{ - CookieDomain: "example.com", - } - - app := bootstrap.NewBootstrapApp(config.Config{}) - db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth_attrs.db")) - require.NoError(t, err) - - queries := repository.New(db) - - docker := service.NewDockerService() - err = docker.Init() - require.NoError(t, err) - - ldap := service.NewLdapService(service.LdapServiceConfig{}) - err = ldap.Init() - require.NoError(t, err) - - broker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig)) - err = broker.Init() - require.NoError(t, err) - - authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker) - err = authService.Init() - require.NoError(t, err) - - makeRouter := func(extraMiddlewares ...gin.HandlerFunc) *gin.Engine { - router := gin.Default() - for _, m := range extraMiddlewares { - router.Use(m) - } - gin.SetMode(gin.TestMode) - group := router.Group("/api") - ctrl := controller.NewUserController(userControllerCfg, group, authService) - ctrl.SetupRoutes() - return router - } - - t.Run("Login uses name and email from user attributes", func(t *testing.T) { - authService.ClearRateLimitsTestingOnly() - router := makeRouter() - - 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") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - require.Equal(t, 200, rec.Code) - cookies := rec.Result().Cookies() - require.Len(t, cookies, 1) - assert.Equal(t, "tinyauth-session", cookies[0].Name) - }) - - t.Run("Login with TOTP uses name and email from user attributes in pending session", func(t *testing.T) { - authService.ClearRateLimitsTestingOnly() - router := makeRouter() - - 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") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - require.Equal(t, 200, rec.Code) - var res map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) - assert.Equal(t, true, res["totpPending"]) - require.Len(t, rec.Result().Cookies(), 1) - }) - - t.Run("TOTP completion uses name and email from user attributes", func(t *testing.T) { - authService.ClearRateLimitsTestingOnly() - - // First: login to get TOTP-pending session - router := makeRouter(func(c *gin.Context) { - c.Set("context", &config.UserContext{ - Username: "attrtotpuser", - Name: "Bob Jones", - Email: "bob@example.com", - Provider: "local", - TotpPending: true, - TotpEnabled: true, - }) - }) - - 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") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - require.Equal(t, 200, rec.Code) - cookies := rec.Result().Cookies() - require.Len(t, cookies, 1) - assert.Equal(t, "tinyauth-session", cookies[0].Name) - }) - - t.Cleanup(func() { - err = db.Close() - require.NoError(t, err) - }) -} diff --git a/internal/service/oidc_service_test.go b/internal/service/oidc_service_test.go index c28300bc..fd174b68 100644 --- a/internal/service/oidc_service_test.go +++ b/internal/service/oidc_service_test.go @@ -46,8 +46,7 @@ func newTestUser() repository.OidcUserinfo { } } -func newOIDCService(t *testing.T) *service.OIDCService { - t.Helper() +func TestCompileUserinfo(t *testing.T) { dir := t.TempDir() svc := service.NewOIDCService(service.OIDCServiceConfig{ PrivateKeyPath: dir + "/key.pem", @@ -56,149 +55,144 @@ func newOIDCService(t *testing.T) *service.OIDCService { SessionExpiry: 3600, }, nil) require.NoError(t, svc.Init()) - return svc -} - -func TestCompileUserinfo_OpenidOnly(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid") - - assert.Equal(t, "test-sub", info.Sub) - assert.Equal(t, int64(1234567890), info.UpdatedAt) - // profile fields not requested - 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) -} - -func TestCompileUserinfo_ProfileScope(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid,profile") - - 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) - // non-profile fields still absent - assert.Empty(t, info.Email) -} - -func TestCompileUserinfo_EmailScope(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid,email") - - assert.Equal(t, "test@example.com", info.Email) - assert.True(t, info.EmailVerified) - assert.Empty(t, info.Name) // profile not requested -} - -func TestCompileUserinfo_EmailScope_Unverified(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - user.Email = "" - - info := svc.CompileUserinfo(user, "openid,email") - - assert.Empty(t, info.Email) - assert.False(t, info.EmailVerified) -} - -func TestCompileUserinfo_PhoneScope(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid,phone") - - assert.Equal(t, "+15555550100", info.PhoneNumber) - require.NotNil(t, info.PhoneNumberVerified) - assert.True(t, *info.PhoneNumberVerified) -} - -func TestCompileUserinfo_PhoneScope_Unverified(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - user.PhoneNumber = "" - info := svc.CompileUserinfo(user, "openid,phone") - - require.NotNil(t, info.PhoneNumberVerified) - assert.False(t, *info.PhoneNumberVerified) -} - -func TestCompileUserinfo_AddressScope(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid,address") - - 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) -} - -func TestCompileUserinfo_AddressScope_InvalidJSON(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - user.Address = "not-valid-json" - - info := svc.CompileUserinfo(user, "openid,address") - - // invalid JSON silently skipped, address omitted - assert.Nil(t, info.Address) -} - -func TestCompileUserinfo_GroupsScope(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - - info := svc.CompileUserinfo(user, "openid,groups") - - assert.Equal(t, []string{"admins", "users"}, info.Groups) -} - -func TestCompileUserinfo_GroupsScope_Empty(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() - user.Groups = "" - - info := svc.CompileUserinfo(user, "openid,groups") - - assert.Equal(t, []string{}, info.Groups) -} - -func TestCompileUserinfo_AllScopes(t *testing.T) { - svc := newOIDCService(t) - user := newTestUser() + type testCase struct { + description string + mutate func(u *repository.OidcUserinfo) + scope string + run func(t *testing.T, info service.UserinfoResponse) + } - info := svc.CompileUserinfo(user, "openid,profile,email,phone,address,groups") + 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) + }, + }, + } - 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) + }) + } } From c6fa4661d97450586ac9e6d419c3eb59fa3c4cca Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Sun, 12 Apr 2026 05:42:11 +1200 Subject: [PATCH 5/8] add claims back to userinfo --- internal/service/oidc_service.go | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 5344e1cd..888ad0e9 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -444,7 +444,7 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client return oidcCode, nil } -func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, nonce string) (string, error) { +func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) { createdAt := time.Now().Unix() expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix() @@ -473,16 +473,20 @@ func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user return "", err } - // Per OIDC Core §5.4: for code flow, scope-requested claims (profile, email) - // belong in the userinfo response only. The id_token carries only the required - // JWT claims (iss, aud, sub, iat, exp) plus nonce. + userInfo := service.CompileUserinfo(user, scope) + claims := ClaimSet{ - Iss: service.issuer, - Aud: client.ClientID, - Sub: user.Sub, - Iat: createdAt, - Exp: expiresAt, - Nonce: nonce, + Iss: service.issuer, + Aud: client.ClientID, + Sub: user.Sub, + Iat: createdAt, + Exp: expiresAt, + Name: userInfo.Name, + Email: userInfo.Email, + EmailVerified: userInfo.EmailVerified, + PreferredUsername: userInfo.PreferredUsername, + Groups: userInfo.Groups, + Nonce: nonce, } payload, err := json.Marshal(claims) @@ -513,7 +517,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OI return TokenResponse{}, err } - idToken, err := service.generateIDToken(client, user, codeEntry.Nonce) + idToken, err := service.generateIDToken(client, user, codeEntry.Scope, codeEntry.Nonce) if err != nil { return TokenResponse{}, err @@ -582,7 +586,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri idToken, err := service.generateIDToken(config.OIDCClientConfig{ ClientID: entry.ClientID, - }, user, entry.Nonce) + }, user, entry.Scope, entry.Nonce) if err != nil { return TokenResponse{}, err From a0096494a092d5639656c0c76a5a287d36cc1c97 Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Fri, 17 Apr 2026 19:57:01 +1200 Subject: [PATCH 6/8] remove redundant column drop in migration --- internal/assets/migrations/000008_oidc_userinfo_profile.down.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql b/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql index 861eeb77..0baa9cfc 100644 --- a/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql +++ b/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql @@ -10,5 +10,4 @@ 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 "phone_number_verified"; ALTER TABLE "oidc_userinfo" DROP COLUMN "address"; From 6510920aa682f4216b90fd42b99ec775195e4375 Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Fri, 17 Apr 2026 20:06:34 +1200 Subject: [PATCH 7/8] fix duplicate migration id --- ...nfo_profile.down.sql => 000009_oidc_userinfo_profile.down.sql} | 0 ...serinfo_profile.up.sql => 000009_oidc_userinfo_profile.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename internal/assets/migrations/{000008_oidc_userinfo_profile.down.sql => 000009_oidc_userinfo_profile.down.sql} (100%) rename internal/assets/migrations/{000008_oidc_userinfo_profile.up.sql => 000009_oidc_userinfo_profile.up.sql} (100%) diff --git a/internal/assets/migrations/000008_oidc_userinfo_profile.down.sql b/internal/assets/migrations/000009_oidc_userinfo_profile.down.sql similarity index 100% rename from internal/assets/migrations/000008_oidc_userinfo_profile.down.sql rename to internal/assets/migrations/000009_oidc_userinfo_profile.down.sql diff --git a/internal/assets/migrations/000008_oidc_userinfo_profile.up.sql b/internal/assets/migrations/000009_oidc_userinfo_profile.up.sql similarity index 100% rename from internal/assets/migrations/000008_oidc_userinfo_profile.up.sql rename to internal/assets/migrations/000009_oidc_userinfo_profile.up.sql From 1265668d8744ae6b801ad3b465c28ad60790741f Mon Sep 17 00:00:00 2001 From: Scott McKendry Date: Mon, 27 Apr 2026 07:50:24 +1200 Subject: [PATCH 8/8] fix clobbered imports post-rebase --- internal/controller/user_controller.go | 1 + internal/service/oidc_service_test.go | 6 +++--- internal/utils/user_utils_test.go | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 89e0d0c1..187b33b9 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" diff --git a/internal/service/oidc_service_test.go b/internal/service/oidc_service_test.go index fd174b68..222ad626 100644 --- a/internal/service/oidc_service_test.go +++ b/internal/service/oidc_service_test.go @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/steveiliop56/tinyauth/internal/config" - "github.com/steveiliop56/tinyauth/internal/repository" - "github.com/steveiliop56/tinyauth/internal/service" + "github.com/tinyauthapp/tinyauth/internal/config" + "github.com/tinyauthapp/tinyauth/internal/repository" + "github.com/tinyauthapp/tinyauth/internal/service" ) func newTestUser() repository.OidcUserinfo { diff --git a/internal/utils/user_utils_test.go b/internal/utils/user_utils_test.go index e2b1c15b..dcbb75cf 100644 --- a/internal/utils/user_utils_test.go +++ b/internal/utils/user_utils_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/tinyauthapp/tinyauth/internal/config" "github.com/tinyauthapp/tinyauth/internal/utils" "gotest.tools/v3/assert"