diff --git a/api/oidc.go b/api/oidc.go
new file mode 100644
index 0000000..bc7ba96
--- /dev/null
+++ b/api/oidc.go
@@ -0,0 +1,206 @@
+package api
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/gotify/server/v2/auth"
+ "github.com/gotify/server/v2/config"
+ "github.com/gotify/server/v2/database"
+ "github.com/gotify/server/v2/model"
+ "github.com/zitadel/oidc/v3/pkg/client/rp"
+ httphelper "github.com/zitadel/oidc/v3/pkg/http"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
+)
+
+func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI {
+ scopes := conf.OIDC.Scopes
+ if len(scopes) == 0 {
+ scopes = []string{"openid", "profile", "email"}
+ }
+
+ cookieKey := make([]byte, 32)
+ if _, err := rand.Read(cookieKey); err != nil {
+ log.Fatalf("failed to generate OIDC cookie key: %v", err)
+ }
+ cookieHandlerOpt := []httphelper.CookieHandlerOpt{}
+ if !conf.Server.SecureCookie {
+ cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure())
+ }
+ cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...)
+
+ opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)}
+
+ provider, err := rp.NewRelyingPartyOIDC(
+ context.Background(),
+ conf.OIDC.Issuer,
+ conf.OIDC.ClientID,
+ conf.OIDC.ClientSecret,
+ conf.OIDC.RedirectURL,
+ scopes,
+ opts...,
+ )
+ if err != nil {
+ log.Fatalf("failed to initialize OIDC provider: %v", err)
+ }
+
+ return &OIDCAPI{
+ DB: db,
+ Provider: provider,
+ UserChangeNotifier: userChangeNotifier,
+ UsernameClaim: conf.OIDC.UsernameClaim,
+ PasswordStrength: conf.PassStrength,
+ SecureCookie: conf.Server.SecureCookie,
+ AutoRegister: conf.OIDC.AutoRegister,
+ }
+}
+
+// OIDCAPI provides handlers for OIDC authentication.
+type OIDCAPI struct {
+ DB *database.GormDatabase
+ Provider rp.RelyingParty
+ UserChangeNotifier *UserChangeNotifier
+ UsernameClaim string
+ PasswordStrength int
+ SecureCookie bool
+ AutoRegister bool
+}
+
+// swagger:operation GET /auth/oidc/login oidc oidcLogin
+//
+// Start the OIDC login flow (browser).
+//
+// Redirects the user to the OIDC provider's authorization endpoint.
+// After authentication, the provider redirects back to the callback endpoint.
+//
+// ---
+// parameters:
+// - name: name
+// in: query
+// description: the client name to create after login
+// required: true
+// type: string
+// responses:
+// 302:
+// description: Redirect to OIDC provider
+// default:
+// description: Error
+// schema:
+// $ref: "#/definitions/Error"
+func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
+ return gin.WrapF(func(w http.ResponseWriter, r *http.Request) {
+ clientName := r.URL.Query().Get("name")
+ if clientName == "" {
+ http.Error(w, "invalid client name", http.StatusBadRequest)
+ return
+ }
+ state, err := a.generateState(clientName)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError)
+ return
+ }
+ rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r)
+ })
+}
+
+// swagger:operation GET /auth/oidc/callback oidc oidcCallback
+//
+// Handle the OIDC provider callback (browser).
+//
+// Exchanges the authorization code for tokens, resolves the user,
+// creates a gotify client, sets a session cookie, and redirects to the UI.
+//
+// ---
+// parameters:
+// - name: code
+// in: query
+// description: the authorization code from the OIDC provider
+// required: true
+// type: string
+// - name: state
+// in: query
+// description: the state parameter for CSRF protection
+// required: true
+// type: string
+// responses:
+// 307:
+// description: Redirect to UI
+// default:
+// description: Error
+// schema:
+// $ref: "#/definitions/Error"
+func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
+ callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
+ user, status, err := a.resolveUser(info)
+ if err != nil {
+ http.Error(w, err.Error(), status)
+ return
+ }
+ clientName, _, _ := strings.Cut(state, ":")
+ client, err := a.createClient(clientName, user.ID)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError)
+ return
+ }
+ auth.SetCookie(w, client.Token, auth.CookieMaxAge, a.SecureCookie)
+ // A reverse proxy may have already stripped a url prefix from the URL
+ // without us knowing, we have to make a relative redirect.
+ // We cannot use http.Redirect as this normalizes the Path with r.URL.
+ w.Header().Set("Location", "../../")
+ w.WriteHeader(http.StatusTemporaryRedirect)
+ }
+ return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider))
+}
+
+func (a *OIDCAPI) generateState(name string) (string, error) {
+ nonce := make([]byte, 20)
+ if _, err := rand.Read(nonce); err != nil {
+ return "", err
+ }
+ return name + ":" + hex.EncodeToString(nonce), nil
+}
+
+// resolveUser looks up or creates a user from OIDC userinfo claims.
+func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) {
+ usernameRaw, ok := info.Claims[a.UsernameClaim]
+ if !ok {
+ return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim)
+ }
+ username := fmt.Sprint(usernameRaw)
+ if username == "" || usernameRaw == nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("username claim was empty")
+ }
+
+ user, err := a.DB.GetUserByName(username)
+ if err != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
+ }
+ if user == nil {
+ if !a.AutoRegister {
+ return nil, http.StatusForbidden, fmt.Errorf("user does not exist and auto-registration is disabled")
+ }
+ user = &model.User{Name: username, Admin: false, Pass: nil}
+ if err := a.DB.CreateUser(user); err != nil {
+ return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
+ }
+ if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
+ log.Printf("Could not notify user change: %v\n", err)
+ }
+ }
+ return user, 0, nil
+}
+
+func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) {
+ client := &model.Client{
+ Name: name,
+ Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }),
+ UserID: userID,
+ }
+ return client, a.DB.CreateClient(client)
+}
diff --git a/config.example.yml b/config.example.yml
index 95bb986..2fed4eb 100644
--- a/config.example.yml
+++ b/config.example.yml
@@ -48,6 +48,14 @@ server:
allowedorigins: # allowed origins for websocket connections (same origin is always allowed)
# - ".+.example.com"
# - "otherdomain.com"
+oidc:
+ enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google).
+ 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"
+ clientid: # The client ID registered with your identity provider for this application.
+ clientsecret: # The client secret for the registered client.
+ 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.
+ autoregister: true # If true, automatically create a new user on first OIDC login. If false, only existing users can log in via OIDC.
+ usernameclaim: preferred_username # The OIDC claim used to determine the username. Common values: "preferred_username" or "email".
database: # for database see (configure database section)
dialect: sqlite3
diff --git a/config/config.go b/config/config.go
index 22fa033..c299d8e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -56,6 +56,16 @@ type Configuration struct {
UploadedImagesDir string `default:"data/images"`
PluginsDir string `default:"data/plugins"`
Registration bool `default:"false"`
+ OIDC struct {
+ Enabled bool `default:"false"`
+ Issuer string `default:""`
+ ClientID string `default:""`
+ ClientSecret string `default:""`
+ UsernameClaim string `default:"preferred_username"`
+ RedirectURL string `default:""`
+ AutoRegister bool `default:"true"`
+ Scopes []string
+ }
}
func configFiles() []string {
diff --git a/docs/spec.json b/docs/spec.json
index 1e26969..787c873 100644
--- a/docs/spec.json
+++ b/docs/spec.json
@@ -587,6 +587,73 @@
}
}
},
+ "/auth/oidc/callback": {
+ "get": {
+ "description": "Exchanges the authorization code for tokens, resolves the user,\ncreates a gotify client, sets a session cookie, and redirects to the UI.",
+ "tags": [
+ "oidc"
+ ],
+ "summary": "Handle the OIDC provider callback (browser).",
+ "operationId": "oidcCallback",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "the authorization code from the OIDC provider",
+ "name": "code",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "the state parameter for CSRF protection",
+ "name": "state",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "307": {
+ "description": "Redirect to UI"
+ },
+ "default": {
+ "description": "Error",
+ "schema": {
+ "$ref": "#/definitions/Error"
+ }
+ }
+ }
+ }
+ },
+ "/auth/oidc/login": {
+ "get": {
+ "description": "Redirects the user to the OIDC provider's authorization endpoint.\nAfter authentication, the provider redirects back to the callback endpoint.",
+ "tags": [
+ "oidc"
+ ],
+ "summary": "Start the OIDC login flow (browser).",
+ "operationId": "oidcLogin",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "the client name to create after login",
+ "name": "name",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Redirect to OIDC provider"
+ },
+ "default": {
+ "description": "Error",
+ "schema": {
+ "$ref": "#/definitions/Error"
+ }
+ }
+ }
+ }
+ },
"/client": {
"get": {
"security": [
diff --git a/go.mod b/go.mod
index 0a9aa51..c519e2c 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/mattn/go-isatty v0.0.20
github.com/robfig/cron v1.2.0
github.com/stretchr/testify v1.11.1
+ github.com/zitadel/oidc/v3 v3.45.5
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -28,15 +29,21 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
@@ -49,15 +56,24 @@ require (
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/muhlemmer/gu v0.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/sirupsen/logrus v1.9.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
+ github.com/zitadel/logging v0.7.0 // indirect
+ github.com/zitadel/schema v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.40.0 // indirect
+ go.opentelemetry.io/otel/metric v1.40.0 // indirect
+ go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
diff --git a/go.sum b/go.sum
index 2d258e7..fbb19cb 100644
--- a/go.sum
+++ b/go.sum
@@ -3,12 +3,16 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -28,6 +32,15 @@ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fq
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -46,6 +59,12 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms=
@@ -62,6 +81,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA=
+github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -90,6 +111,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
+github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
+github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -100,8 +125,12 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -119,8 +148,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ=
+github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ=
+github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrDQ=
+github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE=
+github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
+github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
+go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
+go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
+go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
+go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
+go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
@@ -130,6 +173,8 @@ golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVo
golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
@@ -147,6 +192,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/router/router.go b/router/router.go
index 9491838..d7aa91a 100644
--- a/router/router.go
+++ b/router/router.go
@@ -104,7 +104,14 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser)
userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID)
- ui.Register(g, *vInfo, conf.Registration)
+ ui.Register(g, *vInfo, conf.Registration, conf.OIDC.Enabled)
+
+ if conf.OIDC.Enabled {
+ oidcHandler := api.NewOIDC(conf, db, userChangeNotifier)
+ oidcGroup := g.Group("/auth/oidc")
+ oidcGroup.GET("/login", oidcHandler.LoginHandler())
+ oidcGroup.GET("/callback", oidcHandler.CallbackHandler())
+ }
g.Match([]string{"GET", "HEAD"}, "/health", healthHandler.Health)
g.GET("/swagger", docs.Serve)
diff --git a/ui/serve.go b/ui/serve.go
index 0766493..45e46f4 100644
--- a/ui/serve.go
+++ b/ui/serve.go
@@ -18,11 +18,12 @@ var box embed.FS
type uiConfig struct {
Register bool `json:"register"`
Version model.VersionInfo `json:"version"`
+ OIDC bool `json:"oidc"`
}
// Register registers the ui on the root path.
-func Register(r *gin.Engine, version model.VersionInfo, register bool) {
- uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register})
+func Register(r *gin.Engine, version model.VersionInfo, register, oidcEnabled bool) {
+ uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register, OIDC: oidcEnabled})
if err != nil {
panic(err)
}
diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts
index e0df3e2..38b427c 100644
--- a/ui/src/CurrentUser.ts
+++ b/ui/src/CurrentUser.ts
@@ -38,13 +38,17 @@ export class CurrentUser {
return false;
});
+ public createClientName = (): string => {
+ const browser = detect();
+ return (browser && browser.name + ' ' + browser.version) || 'unknown browser';
+ };
+
public login = async (username: string, password: string) => {
runInAction(() => {
this.loggedIn = false;
this.authenticating = true;
});
- const browser = detect();
- const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser';
+ const name = this.createClientName();
axios
.create()
.request({
diff --git a/ui/src/config.ts b/ui/src/config.ts
index 16c81d2..00981de 100644
--- a/ui/src/config.ts
+++ b/ui/src/config.ts
@@ -4,6 +4,7 @@ export interface IConfig {
url: string;
register: boolean;
version: IVersion;
+ oidc: boolean;
}
declare global {
@@ -16,6 +17,7 @@ const config: IConfig = {
url: 'unset',
register: false,
version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'},
+ oidc: false,
...window.config,
};
diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx
index 161e6ea..e6e572a 100644
--- a/ui/src/user/Login.tsx
+++ b/ui/src/user/Login.tsx
@@ -1,4 +1,5 @@
import Button from '@mui/material/Button';
+import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import TextField from '@mui/material/TextField';
import React from 'react';
@@ -80,6 +81,24 @@ const Login = observer(() => {
Login
+ {config.get('oidc') && (
+ <>
+ or
+
+ >
+ )}
{registerDialog && (