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
);