Skip to content

Commit 5d95123

Browse files
feat(oidc): support for all in-spec attributes and scopes (#777)
* feat(oidc): support for all in-spec attributes and scopes * add tests * assert phone/email verified when either is set * update tests * add claims back to userinfo * remove redundant column drop in migration * fix duplicate migration id * fix clobbered imports post-rebase
1 parent c364b86 commit 5d95123

19 files changed

Lines changed: 687 additions & 111 deletions

frontend/src/lib/i18n/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,9 @@
8080
"profileScopeDescription": "Allows the app to access your profile information.",
8181
"groupsScopeName": "Groups",
8282
"groupsScopeDescription": "Allows the app to access your group information.",
83-
"backToLoginButton": "Back to login"
83+
"backToLoginButton": "Back to login",
84+
"phoneScopeName": "Phone",
85+
"phoneScopeDescription": "Allows the app to access your phone number.",
86+
"addressScopeName": "Address",
87+
"addressScopeDescription": "Allows the app to access your address."
8488
}

frontend/src/pages/authorize-page.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { toast } from "sonner";
1717
import { useOIDCParams } from "@/lib/hooks/oidc";
1818
import { useTranslation } from "react-i18next";
1919
import { TFunction } from "i18next";
20-
import { Mail, Shield, User, Users } from "lucide-react";
20+
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
2121
import {
2222
Tooltip,
2323
TooltipContent,
@@ -61,6 +61,18 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
6161
description: t("groupsScopeDescription"),
6262
icon: <Users {...scopeMapIconProps} />,
6363
},
64+
{
65+
id: "phone",
66+
name: t("phoneScopeName"),
67+
description: t("phoneScopeDescription"),
68+
icon: <Phone {...scopeMapIconProps} />,
69+
},
70+
{
71+
id: "address",
72+
name: t("addressScopeName"),
73+
description: t("addressScopeDescription"),
74+
icon: <MapPin {...scopeMapIconProps} />,
75+
},
6476
];
6577
};
6678

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
ALTER TABLE "oidc_userinfo" DROP COLUMN "given_name";
2+
ALTER TABLE "oidc_userinfo" DROP COLUMN "family_name";
3+
ALTER TABLE "oidc_userinfo" DROP COLUMN "middle_name";
4+
ALTER TABLE "oidc_userinfo" DROP COLUMN "nickname";
5+
ALTER TABLE "oidc_userinfo" DROP COLUMN "profile";
6+
ALTER TABLE "oidc_userinfo" DROP COLUMN "picture";
7+
ALTER TABLE "oidc_userinfo" DROP COLUMN "website";
8+
ALTER TABLE "oidc_userinfo" DROP COLUMN "gender";
9+
ALTER TABLE "oidc_userinfo" DROP COLUMN "birthdate";
10+
ALTER TABLE "oidc_userinfo" DROP COLUMN "zoneinfo";
11+
ALTER TABLE "oidc_userinfo" DROP COLUMN "locale";
12+
ALTER TABLE "oidc_userinfo" DROP COLUMN "phone_number";
13+
ALTER TABLE "oidc_userinfo" DROP COLUMN "address";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
ALTER TABLE "oidc_userinfo" ADD COLUMN "given_name" TEXT NOT NULL DEFAULT "";
2+
ALTER TABLE "oidc_userinfo" ADD COLUMN "family_name" TEXT NOT NULL DEFAULT "";
3+
ALTER TABLE "oidc_userinfo" ADD COLUMN "middle_name" TEXT NOT NULL DEFAULT "";
4+
ALTER TABLE "oidc_userinfo" ADD COLUMN "nickname" TEXT NOT NULL DEFAULT "";
5+
ALTER TABLE "oidc_userinfo" ADD COLUMN "profile" TEXT NOT NULL DEFAULT "";
6+
ALTER TABLE "oidc_userinfo" ADD COLUMN "picture" TEXT NOT NULL DEFAULT "";
7+
ALTER TABLE "oidc_userinfo" ADD COLUMN "website" TEXT NOT NULL DEFAULT "";
8+
ALTER TABLE "oidc_userinfo" ADD COLUMN "gender" TEXT NOT NULL DEFAULT "";
9+
ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL DEFAULT "";
10+
ALTER TABLE "oidc_userinfo" ADD COLUMN "zoneinfo" TEXT NOT NULL DEFAULT "";
11+
ALTER TABLE "oidc_userinfo" ADD COLUMN "locale" TEXT NOT NULL DEFAULT "";
12+
ALTER TABLE "oidc_userinfo" ADD COLUMN "phone_number" TEXT NOT NULL DEFAULT "";
13+
ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}";

internal/bootstrap/app_bootstrap.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func (app *BootstrapApp) Setup() error {
6363
}
6464

6565
// Parse users
66-
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
66+
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
6767

6868
if err != nil {
6969
return err

internal/config/config.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,43 @@ type ServerConfig struct {
113113
}
114114

115115
type AuthConfig struct {
116-
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
117-
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
118-
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
119-
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
120-
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
121-
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
122-
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
123-
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
124-
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
116+
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
117+
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
118+
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
119+
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
120+
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
121+
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
122+
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
123+
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
124+
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
125+
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
126+
}
127+
128+
type UserAttributes struct {
129+
Name string `description:"Full name of the user." yaml:"name"`
130+
GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
131+
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
132+
MiddleName string `description:"Middle name of the user." yaml:"middleName"`
133+
Nickname string `description:"Nickname of the user." yaml:"nickname"`
134+
Profile string `description:"URL of the user's profile page." yaml:"profile"`
135+
Picture string `description:"URL of the user's profile picture." yaml:"picture"`
136+
Website string `description:"URL of the user's website." yaml:"website"`
137+
Email string `description:"Email address of the user." yaml:"email"`
138+
Gender string `description:"Gender of the user." yaml:"gender"`
139+
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"`
140+
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"`
141+
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
142+
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
143+
Address AddressClaim `description:"Address of the user." yaml:"address"`
144+
}
145+
146+
type AddressClaim struct {
147+
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
148+
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
149+
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
150+
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
151+
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
152+
Country string `description:"Country." yaml:"country" json:"country,omitempty"`
125153
}
126154

127155
type IPConfig struct {
@@ -228,6 +256,7 @@ type User struct {
228256
Username string
229257
Password string
230258
TotpSecret string
259+
Attributes UserAttributes
231260
}
232261

233262
type LdapUser struct {
@@ -254,6 +283,7 @@ type UserContext struct {
254283
OAuthName string
255284
OAuthSub string
256285
LdapGroups string
286+
Attributes UserAttributes
257287
}
258288

259289
// API responses and queries

internal/controller/user_controller.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"time"
66

7+
"github.com/tinyauthapp/tinyauth/internal/config"
78
"github.com/tinyauthapp/tinyauth/internal/repository"
89
"github.com/tinyauthapp/tinyauth/internal/service"
910
"github.com/tinyauthapp/tinyauth/internal/utils"
@@ -105,16 +106,32 @@ func (controller *UserController) loginHandler(c *gin.Context) {
105106

106107
controller.auth.RecordLoginAttempt(req.Username, true)
107108

109+
var localUser *config.User
108110
if userSearch.Type == "local" {
109111
user := controller.auth.GetLocalUser(userSearch.Username)
112+
localUser = &user
113+
}
114+
115+
if userSearch.Type == "local" && localUser != nil {
116+
user := *localUser
110117

111118
if user.TotpSecret != "" {
112119
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
113120

121+
name := user.Attributes.Name
122+
if name == "" {
123+
name = utils.Capitalize(user.Username)
124+
}
125+
126+
email := user.Attributes.Email
127+
if email == "" {
128+
email = utils.CompileUserEmail(user.Username, controller.config.CookieDomain)
129+
}
130+
114131
err := controller.auth.CreateSessionCookie(c, &repository.Session{
115132
Username: user.Username,
116-
Name: utils.Capitalize(user.Username),
117-
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
133+
Name: name,
134+
Email: email,
118135
Provider: "local",
119136
TotpPending: true,
120137
})
@@ -144,6 +161,15 @@ func (controller *UserController) loginHandler(c *gin.Context) {
144161
Provider: "local",
145162
}
146163

164+
if userSearch.Type == "local" && localUser != nil {
165+
if localUser.Attributes.Name != "" {
166+
sessionCookie.Name = localUser.Attributes.Name
167+
}
168+
if localUser.Attributes.Email != "" {
169+
sessionCookie.Email = localUser.Attributes.Email
170+
}
171+
}
172+
147173
if userSearch.Type == "ldap" {
148174
sessionCookie.Provider = "ldap"
149175
}
@@ -258,6 +284,13 @@ func (controller *UserController) totpHandler(c *gin.Context) {
258284
Provider: "local",
259285
}
260286

287+
if user.Attributes.Name != "" {
288+
sessionCookie.Name = user.Attributes.Name
289+
}
290+
if user.Attributes.Email != "" {
291+
sessionCookie.Email = user.Attributes.Email
292+
}
293+
261294
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
262295

263296
err = controller.auth.CreateSessionCookie(c, &sessionCookie)

internal/controller/user_controller_test.go

Lines changed: 103 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"encoding/json"
55
"net/http/httptest"
66
"path"
7-
"slices"
87
"strings"
98
"testing"
109
"time"
@@ -36,6 +35,23 @@ func TestUserController(t *testing.T) {
3635
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
3736
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
3837
},
38+
{
39+
Username: "attruser",
40+
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
41+
Attributes: config.UserAttributes{
42+
Name: "Alice Smith",
43+
Email: "alice@example.com",
44+
},
45+
},
46+
{
47+
Username: "attrtotpuser",
48+
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
49+
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
50+
Attributes: config.UserAttributes{
51+
Name: "Bob Jones",
52+
Email: "bob@example.com",
53+
},
54+
},
3955
},
4056
SessionExpiry: 10, // 10 seconds, useful for testing
4157
CookieDomain: "example.com",
@@ -273,6 +289,64 @@ func TestUserController(t *testing.T) {
273289
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
274290
},
275291
},
292+
{
293+
description: "Login uses name and email from user attributes",
294+
middlewares: []gin.HandlerFunc{},
295+
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
296+
loginReq := controller.LoginRequest{Username: "attruser", Password: "password"}
297+
body, err := json.Marshal(loginReq)
298+
require.NoError(t, err)
299+
300+
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
301+
req.Header.Set("Content-Type", "application/json")
302+
router.ServeHTTP(recorder, req)
303+
304+
require.Equal(t, 200, recorder.Code)
305+
cookies := recorder.Result().Cookies()
306+
require.Len(t, cookies, 1)
307+
assert.Equal(t, "tinyauth-session", cookies[0].Name)
308+
},
309+
},
310+
{
311+
description: "Login with TOTP uses name and email from user attributes in pending session",
312+
middlewares: []gin.HandlerFunc{},
313+
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
314+
loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"}
315+
body, err := json.Marshal(loginReq)
316+
require.NoError(t, err)
317+
318+
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
319+
req.Header.Set("Content-Type", "application/json")
320+
router.ServeHTTP(recorder, req)
321+
322+
require.Equal(t, 200, recorder.Code)
323+
var res map[string]any
324+
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
325+
assert.Equal(t, true, res["totpPending"])
326+
require.Len(t, recorder.Result().Cookies(), 1)
327+
},
328+
},
329+
{
330+
description: "TOTP completion uses name and email from user attributes",
331+
middlewares: []gin.HandlerFunc{},
332+
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
333+
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
334+
require.NoError(t, err)
335+
336+
totpReq := controller.TotpRequest{Code: code}
337+
body, err := json.Marshal(totpReq)
338+
require.NoError(t, err)
339+
340+
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body)))
341+
req.Header.Set("Content-Type", "application/json")
342+
router.ServeHTTP(recorder, req)
343+
344+
require.Equal(t, 200, recorder.Code)
345+
cookies := recorder.Result().Cookies()
346+
require.Len(t, cookies, 1)
347+
assert.Equal(t, "tinyauth-session", cookies[0].Name)
348+
},
349+
},
276350
}
277351

278352
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
@@ -305,9 +379,31 @@ func TestUserController(t *testing.T) {
305379
authService.ClearRateLimitsTestingOnly()
306380
}
307381

308-
setTotpMiddlewareOverrides := []string{
309-
"Should be able to login with totp",
310-
"Totp should rate limit on multiple invalid attempts",
382+
setTotpMiddlewareOverrides := map[string]config.UserContext{
383+
"Should be able to login with totp": {
384+
Username: "totpuser",
385+
Name: "Totpuser",
386+
Email: "totpuser@example.com",
387+
Provider: "local",
388+
TotpPending: true,
389+
TotpEnabled: true,
390+
},
391+
"Totp should rate limit on multiple invalid attempts": {
392+
Username: "totpuser",
393+
Name: "Totpuser",
394+
Email: "totpuser@example.com",
395+
Provider: "local",
396+
TotpPending: true,
397+
TotpEnabled: true,
398+
},
399+
"TOTP completion uses name and email from user attributes": {
400+
Username: "attrtotpuser",
401+
Name: "Bob Jones",
402+
Email: "bob@example.com",
403+
Provider: "local",
404+
TotpPending: true,
405+
TotpEnabled: true,
406+
},
311407
}
312408

313409
for _, test := range tests {
@@ -321,18 +417,10 @@ func TestUserController(t *testing.T) {
321417

322418
// Gin is stupid and doesn't allow setting a middleware after the groups
323419
// so we need to do some stupid overrides here
324-
if slices.Contains(setTotpMiddlewareOverrides, test.description) {
325-
// Assuming the cookie is set, it should be picked up by the
326-
// context middleware
420+
if ctx, ok := setTotpMiddlewareOverrides[test.description]; ok {
421+
ctx := ctx
327422
router.Use(func(c *gin.Context) {
328-
c.Set("context", &config.UserContext{
329-
Username: "totpuser",
330-
Name: "Totpuser",
331-
Email: "totpuser@example.com",
332-
Provider: "local",
333-
TotpPending: true,
334-
TotpEnabled: true,
335-
})
423+
c.Set("context", &ctx)
336424
})
337425
}
338426

internal/controller/well_known_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
6161
SubjectTypesSupported: []string{"pairwise"},
6262
IDTokenSigningAlgValuesSupported: []string{"RS256"},
6363
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
64-
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
64+
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"},
6565
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
6666
RequestParameterSupported: true,
6767
RequestObjectSigningAlgValuesSupported: []string{"none"},

internal/controller/well_known_controller_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func TestWellKnownController(t *testing.T) {
6767
SubjectTypesSupported: []string{"pairwise"},
6868
IDTokenSigningAlgValuesSupported: []string{"RS256"},
6969
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
70-
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
70+
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"},
7171
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
7272
RequestParameterSupported: true,
7373
RequestObjectSigningAlgValuesSupported: []string{"none"},

0 commit comments

Comments
 (0)