Skip to content

Commit 9a5063f

Browse files
Merge remote-tracking branch 'origin/master' into pk-token
2 parents 0d294f9 + 8221c0b commit 9a5063f

36 files changed

Lines changed: 1870 additions & 790 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ on: [push, pull_request]
44
jobs:
55
gotify:
66
runs-on: ubuntu-latest
7+
# Run on push for branches in this repo, and on pull_request only for forks.
8+
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.repository != github.event.pull_request.head.repo.full_name)
79
steps:
810
- uses: actions/setup-go@v6
911
with:
@@ -23,7 +25,7 @@ jobs:
2325
- run: make download-tools
2426
- run: make test
2527
- run: make check-ci
26-
- uses: codecov/codecov-action@v6
28+
- uses: codecov/codecov-action@v7
2729
with:
2830
token: ${{ secrets.CODECOV_TOKEN }}
2931
- if: startsWith(github.ref, 'refs/tags/v')

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ coverage.txt
99
**/*-packr.go
1010
config.yml
1111
data/
12-
images/
12+
images/
13+
/gotify-server.env

api/oidc.go

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"net/url"
12+
"strings"
1113
"time"
1214

1315
"github.com/gin-gonic/gin"
@@ -23,11 +25,6 @@ import (
2325
)
2426

2527
func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI {
26-
scopes := conf.OIDC.Scopes
27-
if len(scopes) == 0 {
28-
scopes = []string{"openid", "profile", "email"}
29-
}
30-
3128
cookieKey := make([]byte, 32)
3229
if _, err := rand.Read(cookieKey); err != nil {
3330
log.Fatal().Err(err).Msg("failed to generate OIDC cookie key")
@@ -38,15 +35,19 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
3835
}
3936
cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...)
4037

41-
opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)}
38+
opts := []rp.Option{
39+
rp.WithCookieHandler(cookieHandler),
40+
rp.WithPKCE(cookieHandler),
41+
rp.WithSigningAlgsFromDiscovery(),
42+
}
4243

4344
provider, err := rp.NewRelyingPartyOIDC(
4445
context.Background(),
4546
conf.OIDC.Issuer,
4647
conf.OIDC.ClientID,
4748
conf.OIDC.ClientSecret,
4849
conf.OIDC.RedirectURL,
49-
scopes,
50+
conf.OIDC.Scopes,
5051
opts...,
5152
)
5253
if err != nil {
@@ -61,6 +62,7 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
6162
PasswordStrength: conf.PassStrength,
6263
SecureCookie: conf.Server.SecureCookie,
6364
AutoRegister: conf.OIDC.AutoRegister,
65+
LinkByUsername: conf.OIDC.LinkByUsername,
6466
pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge),
6567
}
6668
}
@@ -88,6 +90,7 @@ type OIDCAPI struct {
8890
PasswordStrength int
8991
SecureCookie bool
9092
AutoRegister bool
93+
LinkByUsername bool
9194
pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession]
9295
}
9396

@@ -201,7 +204,7 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
201204
// $ref: "#/definitions/Error"
202205
func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
203206
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
204-
user, status, err := a.resolveUser(info)
207+
user, status, err := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
205208
if err != nil {
206209
http.Error(w, err.Error(), status)
207210
return
@@ -367,7 +370,7 @@ func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
367370
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err))
368371
return
369372
}
370-
user, status, resolveErr := a.resolveUser(info)
373+
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
371374
if resolveErr != nil {
372375
ctx.AbortWithError(status, resolveErr)
373376
return
@@ -391,32 +394,83 @@ func (a *OIDCAPI) generateState() (string, error) {
391394
return hex.EncodeToString(nonce), nil
392395
}
393396

394-
// resolveUser looks up or creates a user from OIDC userinfo claims.
395-
func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) {
397+
// resolveUser looks up, links, or creates the user bound to an OIDC identity.
398+
//
399+
// 1. Look up the user by OIDC id (<iss>#<sub>). If found, use it.
400+
// 2. Otherwise look up a user by the username claim. If one exists, link it to
401+
// this OIDC identity, which requires GOTIFY_OIDC_LINK_BY_USERNAME and
402+
// that the user is not already bound to a different identity.
403+
// 3. Otherwise auto-register a new user, which requires GOTIFY_OIDC_AUTOREGISTER.
404+
func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User, int, error) {
405+
if issuer == "" {
406+
return nil, http.StatusInternalServerError, errors.New("issuer claim was empty")
407+
}
408+
if _, err := url.Parse(issuer); err != nil {
409+
return nil, http.StatusInternalServerError, fmt.Errorf("issuer url %q is not a valid url: %w", issuer, err)
410+
}
411+
if strings.Contains(issuer, "#") {
412+
return nil, http.StatusInternalServerError, fmt.Errorf("issuer url %q may not contain a fragment", issuer)
413+
}
414+
subject := info.GetSubject()
415+
if subject == "" {
416+
return nil, http.StatusInternalServerError, errors.New("subject claim was empty")
417+
}
418+
oidcID := issuer + "#" + subject
419+
420+
user, err := a.DB.GetUserByOIDC(oidcID)
421+
if err != nil {
422+
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
423+
}
424+
if user != nil {
425+
return user, 0, nil
426+
}
427+
396428
usernameRaw, ok := info.Claims[a.UsernameClaim]
397429
if !ok {
398430
return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim)
399431
}
400432
username := fmt.Sprint(usernameRaw)
401433
if username == "" || usernameRaw == nil {
402-
return nil, http.StatusInternalServerError, fmt.Errorf("username claim was empty")
434+
return nil, http.StatusInternalServerError, errors.New("username claim was empty")
403435
}
404436

405-
user, err := a.DB.GetUserByName(username)
437+
byUsername, err := a.DB.GetUserByName(username)
406438
if err != nil {
407439
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
408440
}
409-
if user == nil {
410-
if !a.AutoRegister {
411-
return nil, http.StatusForbidden, fmt.Errorf("user does not exist and auto-registration is disabled")
412-
}
413-
user = &model.User{Name: username, Admin: false, Pass: nil}
414-
if err := a.DB.CreateUser(user); err != nil {
415-
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
416-
}
417-
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
418-
log.Error().Err(err).Uint("user_id", user.ID).Msg("Could not notify user change")
419-
}
441+
if byUsername != nil {
442+
return a.linkExistingUser(byUsername, oidcID)
443+
}
444+
return a.registerUser(username, oidcID)
445+
}
446+
447+
func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User, int, error) {
448+
if !a.LinkByUsername {
449+
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Msgf("OIDC login rejected: a local user with the username already exists and %s is disabled", config.EnvOIDCLinkByUsername)
450+
return nil, http.StatusForbidden, fmt.Errorf("a local user with the username %s already exists and linking by username is disabled", user.Name)
451+
}
452+
if user.OIDCID != nil {
453+
log.Warn().Str("oidc_id", oidcID).Str("bound_oidc_id", *user.OIDCID).Str("username", user.Name).Msg("OIDC login rejected: the username is already bound to a different OIDC identity")
454+
return nil, http.StatusForbidden, fmt.Errorf("the user %s is already bound to a different OIDC identity", user.Name)
455+
}
456+
user.OIDCID = &oidcID
457+
if err := a.DB.UpdateUser(user); err != nil {
458+
return nil, http.StatusInternalServerError, fmt.Errorf("failed to bind user to OIDC identity: %w", err)
459+
}
460+
return user, 0, nil
461+
}
462+
463+
func (a *OIDCAPI) registerUser(username, oidcID string) (*model.User, int, error) {
464+
if !a.AutoRegister {
465+
return nil, http.StatusForbidden, errors.New("user does not exist and auto-registration is disabled")
466+
}
467+
user := &model.User{Name: username, Admin: false, Pass: nil, OIDCID: &oidcID}
468+
if err := a.DB.CreateUser(user); err != nil {
469+
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
470+
}
471+
log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Msg("OIDC auto registration")
472+
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
473+
log.Error().Err(err).Uint("user_id", user.ID).Msg("Could not notify user change")
420474
}
421475
return user, 0, nil
422476
}

0 commit comments

Comments
 (0)