From cfaddfe255006bbe9d477cd1d5cdcff208a8e604 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 13:57:18 +0100 Subject: [PATCH] feat: oidc --- api/oidc.go | 206 ++++++++++++++++++++++++++++++++++++++++++ config.example.yml | 8 ++ config/config.go | 10 ++ docs/spec.json | 67 ++++++++++++++ go.mod | 16 ++++ go.sum | 51 ++++++++++- router/router.go | 9 +- ui/serve.go | 5 +- ui/src/CurrentUser.ts | 8 +- ui/src/config.ts | 2 + ui/src/user/Login.tsx | 19 ++++ 11 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 api/oidc.go 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 +