Skip to content

Commit 9e1232e

Browse files
committed
feat: oidc
1 parent 4c9b3db commit 9e1232e

File tree

10 files changed

+284
-24
lines changed

10 files changed

+284
-24
lines changed

api/oidc.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"fmt"
8+
"log"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/gin-gonic/gin"
13+
"github.com/gotify/server/v2/auth"
14+
"github.com/gotify/server/v2/config"
15+
"github.com/gotify/server/v2/database"
16+
"github.com/gotify/server/v2/model"
17+
"github.com/zitadel/oidc/v3/pkg/client/rp"
18+
httphelper "github.com/zitadel/oidc/v3/pkg/http"
19+
"github.com/zitadel/oidc/v3/pkg/oidc"
20+
)
21+
22+
func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI {
23+
scopes := conf.OIDC.Scopes
24+
if len(scopes) == 0 {
25+
scopes = []string{"openid", "profile", "email"}
26+
}
27+
28+
cookieKey := make([]byte, 32)
29+
if _, err := rand.Read(cookieKey); err != nil {
30+
log.Fatalf("failed to generate OIDC cookie key: %v", err)
31+
}
32+
cookieHandlerOpt := []httphelper.CookieHandlerOpt{}
33+
if !conf.Server.SecureCookie {
34+
cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure())
35+
}
36+
cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...)
37+
38+
opts := []rp.Option{rp.WithCookieHandler(cookieHandler)}
39+
if conf.OIDC.Pkce {
40+
opts = append(opts, rp.WithPKCE(cookieHandler))
41+
}
42+
43+
provider, err := rp.NewRelyingPartyOIDC(
44+
context.Background(),
45+
conf.OIDC.Issuer,
46+
conf.OIDC.ClientID,
47+
conf.OIDC.ClientSecret,
48+
conf.OIDC.RedirectURL,
49+
scopes,
50+
opts...,
51+
)
52+
if err != nil {
53+
log.Fatalf("failed to initialize OIDC provider: %v", err)
54+
}
55+
56+
return &OIDCAPI{
57+
DB: db,
58+
Provider: provider,
59+
UserChangeNotifier: userChangeNotifier,
60+
UsernameClaim: conf.OIDC.UsernameClaim,
61+
PasswordStrength: conf.PassStrength,
62+
SecureCookie: conf.Server.SecureCookie,
63+
}
64+
}
65+
66+
// OIDCAPI provides handlers for OIDC authentication.
67+
type OIDCAPI struct {
68+
DB *database.GormDatabase
69+
Provider rp.RelyingParty
70+
UserChangeNotifier *UserChangeNotifier
71+
UsernameClaim string
72+
PasswordStrength int
73+
SecureCookie bool
74+
}
75+
76+
// LoginHandler redirects the user to the OIDC provider's authorization endpoint.
77+
func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
78+
return gin.WrapF(func(w http.ResponseWriter, r *http.Request) {
79+
clientName := r.URL.Query().Get("name")
80+
if clientName == "" {
81+
http.Error(w, "invalid client name", http.StatusBadRequest)
82+
return
83+
}
84+
nonce := make([]byte, 20)
85+
if _, err := rand.Read(nonce); err != nil {
86+
http.Error(w, "failed to generate state nonce", http.StatusInternalServerError)
87+
return
88+
}
89+
state := clientName + ":" + hex.EncodeToString(nonce)
90+
stateFunc := func() string { return state }
91+
rp.AuthURLHandler(stateFunc, a.Provider)(w, r)
92+
})
93+
}
94+
95+
// CallbackHandler handles the OIDC provider callback after authentication.
96+
func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
97+
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
98+
usernameRaw, ok := info.Claims[a.UsernameClaim]
99+
if !ok {
100+
http.Error(w, fmt.Sprintf("username claim %q is missing", a.UsernameClaim), http.StatusInternalServerError)
101+
return
102+
}
103+
104+
username := fmt.Sprint(usernameRaw)
105+
if username == "" || usernameRaw == nil {
106+
http.Error(w, "Username claim was empty", http.StatusInternalServerError)
107+
return
108+
}
109+
110+
user, err := a.DB.GetUserByName(username)
111+
if err != nil {
112+
http.Error(w, "database error", http.StatusInternalServerError)
113+
return
114+
}
115+
116+
if user == nil {
117+
user = &model.User{Name: username, Admin: false, Pass: nil}
118+
if err := a.DB.CreateUser(user); err != nil {
119+
http.Error(w, "failed to create user", http.StatusInternalServerError)
120+
return
121+
}
122+
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
123+
log.Println("Could not notify user change")
124+
// Don't abort here, it's likely some plugin misbehaving.
125+
}
126+
}
127+
128+
token := auth.GenerateNotExistingToken(generateClientToken, func(token string) bool {
129+
client, _ := a.DB.GetClientByToken(token)
130+
return client != nil
131+
})
132+
133+
clientName, _, _ := strings.Cut(state, ":")
134+
client := &model.Client{
135+
Name: clientName,
136+
Token: token,
137+
UserID: user.ID,
138+
}
139+
if err := a.DB.CreateClient(client); err != nil {
140+
http.Error(w, "failed to create client", http.StatusInternalServerError)
141+
return
142+
}
143+
144+
auth.SetCookie(w, token, auth.CookieMaxAge, a.SecureCookie)
145+
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
146+
}
147+
148+
return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider))
149+
}

config.example.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ server:
4848
allowedorigins: # allowed origins for websocket connections (same origin is always allowed)
4949
# - ".+.example.com"
5050
# - "otherdomain.com"
51+
oidc:
52+
enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google).
53+
issuer: # The OIDC issuer URL. This is the base URL of your identity provider, used to discover endpoints. Example: "https://auth.example.com/realms/myrealm"
54+
clientid: # The client ID registered with your identity provider for this application.
55+
clientsecret: # The client secret for the registered client. May be omitted if using a public client with PKCE.
56+
redirecturl: http://gotify.example.org/auth/oidc/callback # The callback URL that the identity provider redirects to after authentication. Must match exactly what is configured in your identity provider.
57+
pkce: true # If PKCE should be used. https://oauth.net/2/pkce/
5158

5259
database: # for database see (configure database section)
5360
dialect: sqlite3

config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ type Configuration struct {
5656
UploadedImagesDir string `default:"data/images"`
5757
PluginsDir string `default:"data/plugins"`
5858
Registration bool `default:"false"`
59+
OIDC struct {
60+
Enabled bool `default:"false"`
61+
Issuer string `default:""`
62+
ClientID string `default:""`
63+
ClientSecret string `default:""`
64+
UsernameClaim string `default:"preferred_username"`
65+
RedirectURL string `default:""`
66+
Pkce bool `default:"true"`
67+
Scopes []string
68+
}
5969
}
6070

6171
func configFiles() []string {

go.mod

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/mattn/go-isatty v0.0.20
1515
github.com/robfig/cron v1.2.0
1616
github.com/stretchr/testify v1.11.1
17+
github.com/zitadel/oidc/v3 v3.45.5
1718
golang.org/x/crypto v0.47.0
1819
gopkg.in/yaml.v3 v3.0.1
1920
gorm.io/driver/mysql v1.6.0
@@ -28,15 +29,21 @@ require (
2829
github.com/bytedance/gopkg v0.1.3 // indirect
2930
github.com/bytedance/sonic v1.14.1 // indirect
3031
github.com/bytedance/sonic/loader v0.3.0 // indirect
32+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
3133
github.com/cloudwego/base64x v0.1.6 // indirect
3234
github.com/davecgh/go-spew v1.1.1 // indirect
3335
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
3436
github.com/gin-contrib/sse v1.1.0 // indirect
37+
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
38+
github.com/go-logr/logr v1.4.3 // indirect
39+
github.com/go-logr/stdr v1.2.2 // indirect
3540
github.com/go-playground/locales v0.14.1 // indirect
3641
github.com/go-playground/universal-translator v0.18.1 // indirect
3742
github.com/go-sql-driver/mysql v1.9.3 // indirect
3843
github.com/goccy/go-json v0.10.5 // indirect
3944
github.com/goccy/go-yaml v1.18.0 // indirect
45+
github.com/google/uuid v1.6.0 // indirect
46+
github.com/gorilla/securecookie v1.1.2 // indirect
4047
github.com/jackc/pgpassfile v1.0.0 // indirect
4148
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
4249
github.com/jackc/pgx/v5 v5.7.6 // indirect
@@ -45,27 +52,35 @@ require (
4552
github.com/jinzhu/now v1.1.5 // indirect
4653
github.com/json-iterator/go v1.1.12 // indirect
4754
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
48-
github.com/kr/text v0.2.0 // indirect
4955
github.com/leodido/go-urn v1.4.0 // indirect
5056
github.com/mattn/go-sqlite3 v1.14.32 // indirect
5157
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
5258
github.com/modern-go/reflect2 v1.0.2 // indirect
59+
github.com/muhlemmer/gu v0.3.1 // indirect
5360
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
5461
github.com/pmezard/go-difflib v1.0.0 // indirect
5562
github.com/quic-go/qpack v0.5.1 // indirect
5663
github.com/quic-go/quic-go v0.55.0 // indirect
64+
github.com/sirupsen/logrus v1.9.4 // indirect
5765
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
5866
github.com/ugorji/go/codec v1.3.0 // indirect
67+
github.com/zitadel/logging v0.7.0 // indirect
68+
github.com/zitadel/schema v1.3.2 // indirect
69+
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
70+
go.opentelemetry.io/otel v1.40.0 // indirect
71+
go.opentelemetry.io/otel/metric v1.40.0 // indirect
72+
go.opentelemetry.io/otel/trace v1.40.0 // indirect
5973
golang.org/x/arch v0.22.0 // indirect
60-
golang.org/x/mod v0.31.0 // indirect
61-
golang.org/x/net v0.48.0 // indirect
74+
golang.org/x/mod v0.32.0 // indirect
75+
golang.org/x/net v0.49.0 // indirect
76+
golang.org/x/oauth2 v0.35.0 // indirect
6277
golang.org/x/sync v0.19.0 // indirect
6378
golang.org/x/sys v0.40.0 // indirect
64-
golang.org/x/text v0.33.0 // indirect
65-
golang.org/x/tools v0.40.0 // indirect
79+
golang.org/x/text v0.34.0 // indirect
80+
golang.org/x/tools v0.41.0 // indirect
6681
google.golang.org/protobuf v1.36.10 // indirect
6782
)
6883

69-
go 1.24.0
84+
go 1.24.10
7085

7186
toolchain go1.26.0

0 commit comments

Comments
 (0)