diff --git a/README.md b/README.md index 3053048..5e6cd91 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Full documentation can be found at [https://autobrr.com](https://autobrr.com) - [Windows](#windows) - [MacOS](#macos) - [Linux Generic](#linux-generic) + - [Environment Variables](#environment-variables) 4. [Community](#community) 5. [Contributing](#contributing) 6. [Code of Conduct](#code-of-conduct) @@ -70,6 +71,7 @@ qBittorrent, Deluge, r(u)Torrent and Transmission. You don't need to use the *ar Windows, macOS) on different architectures (e.g. x86, ARM) - Great container support (Docker, k8s/Kubernetes) - Database engine supporting both PostgreSQL and SQLite +- Authentication support including built-in auth and OpenID Connect (OIDC) - Notifications (Discord, Telegram, Notifiarr, Pushover, Gotify) - One autobrr instance can communicate with multiple clients (torrent, Usenet and \*arr) on remote servers - Base path / Subfolder (and subdomain) support for convenient reverse-proxy support @@ -310,6 +312,36 @@ or [traefik](https://autobrr.com/installation/docker#traefik). If you are not running a reverse proxy change `host` in the `config.toml` to `0.0.0.0`. +### Environment Variables + +The following environment variables can be used: + +| Variable | Description | Default | +| -------------------------------- | ------------------------------------ | ---------------------------------------- | +| `AUTOBRR__HOST` | Listen address | `127.0.0.1` | +| `AUTOBRR__PORT` | Listen port | `7474` | +| `AUTOBRR__BASE_URL` | Base URL for reverse proxy | `/` | +| `AUTOBRR__LOG_LEVEL` | Log level (DEBUG, INFO, WARN, ERROR) | `INFO` | +| `AUTOBRR__LOG_PATH` | Log file location | `/config/logs` | +| `AUTOBRR__LOG_MAX_SIZE` | Max size in MB before rotation | `10` | +| `AUTOBRR__LOG_MAX_BACKUPS` | Number of rotated logs to keep | `5` | +| `AUTOBRR__SESSION_SECRET` | Random string for session encryption | - | +| `AUTOBRR__CUSTOM_DEFINITIONS` | Path to custom indexer definitions | - | +| `AUTOBRR__CHECK_FOR_UPDATES` | Enable update checks | `true` | +| `AUTOBRR__DATABASE_TYPE` | Database type (sqlite/postgres) | `sqlite` | +| `AUTOBRR__POSTGRES_HOST` | PostgreSQL host | - | +| `AUTOBRR__POSTGRES_PORT` | PostgreSQL port | `5432` | +| `AUTOBRR__POSTGRES_DATABASE` | PostgreSQL database name | - | +| `AUTOBRR__POSTGRES_USER` | PostgreSQL username | - | +| `AUTOBRR__POSTGRES_PASS` | PostgreSQL password | - | +| `AUTOBRR__POSTGRES_SSLMODE` | PostgreSQL SSL mode | `disable` | +| `AUTOBRR__POSTGRES_EXTRA_PARAMS` | Additional PostgreSQL parameters | - | +| `AUTOBRR__OIDC_ENABLED` | Enable OpenID Connect authentication | `false` | +| `AUTOBRR__OIDC_ISSUER` | OIDC issuer URL | - | +| `AUTOBRR__OIDC_CLIENT_ID` | OIDC client ID | - | +| `AUTOBRR__OIDC_CLIENT_SECRET` | OIDC client secret | - | +| `AUTOBRR__OIDC_REDIRECT_URL` | OIDC callback URL | `https://baseurl/api/auth/oidc/callback` | + ## Community Join our friendly and welcoming community on [Discord](https://discord.gg/WQ2eUycxyT)! Connect with fellow autobrr users, get advice, and share your experiences. Whether you're seeking help, wanting to contribute, or just looking to discuss your ideas, our community is a hub of discussion and support. We're all here to help each other out, so don't hesitate to jump in! diff --git a/config.toml b/config.toml index e206dbc..97647de 100644 --- a/config.toml +++ b/config.toml @@ -69,6 +69,23 @@ checkForUpdates = true # sessionSecret = "secret-session-key" +# OpenID Connect Configuration +# +# Enable OIDC authentication +#oidc_enabled = false + +# OIDC Issuer URL (e.g. https://auth.example.com) +#oidc_issuer = "" + +# OIDC Client ID +#oidc_client_id = "" + +# OIDC Client Secret +#oidc_client_secret = "" + +# OIDC Redirect URL (e.g. http://localhost:7474/api/auth/oidc/callback) +#oidc_redirect_url = "" + # Custom definitions # #customDefinitions = "test/definitions" diff --git a/go.mod b/go.mod index f7d5ba7..b81fb1e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/avast/retry-go v3.0.0+incompatible github.com/avast/retry-go/v4 v4.6.0 github.com/containrrr/shoutrrr v0.8.0 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/dcarbone/zadapters/zstdlog v1.0.0 github.com/dustin/go-humanize v1.0.1 github.com/ergochat/irc-go v0.4.0 @@ -28,7 +29,6 @@ require ( github.com/hashicorp/go-version v1.7.0 github.com/hekmon/transmissionrpc/v3 v3.0.0 github.com/icholy/digest v1.0.1 - github.com/jellydator/ttlcache/v3 v3.3.0 github.com/lib/pq v1.10.9 github.com/mattn/go-shellwords v1.0.12 github.com/mmcdole/gofeed v1.3.0 @@ -45,6 +45,7 @@ require ( go.uber.org/automaxprocs v1.6.0 golang.org/x/crypto v0.29.0 golang.org/x/net v0.31.0 + golang.org/x/oauth2 v0.21.0 golang.org/x/sync v0.9.0 golang.org/x/term v0.26.0 golang.org/x/time v0.8.0 @@ -73,6 +74,7 @@ require ( github.com/docker/go-units v0.4.0 // indirect github.com/fatih/color v1.16.0 // indirect github.com/gdm85/go-rencode v0.1.8 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/godbus/dbus/v5 v5.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -115,6 +117,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/sys v0.27.0 // indirect diff --git a/go.sum b/go.sum index 8ccb4bb..7c1c40c 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/containerd/cgroups/v3 v3.0.1 h1:4hfGvu8rfGIwVIDd+nLzn/B9ZXx4BcCjzt5To github.com/containerd/cgroups/v3 v3.0.1/go.mod h1:/vtwk1VXrtoa5AaZLkypuOJgA/6DyPMZHJPGQNtlHnw= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cytec/releaseparser v0.0.0-20200706155913-2341b265c370 h1:g9q5BGfDdhcXn4EmVZD8UydPXrvhSvgz3FRBn7zAJNs= @@ -134,6 +136,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -217,8 +221,6 @@ github.com/icholy/digest v1.0.1 h1:HBhK5/Ab2Z4rHgw6n5UooxcJpLSeMR+TuD5rkvRc7Z8= github.com/icholy/digest v1.0.1/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= -github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= -github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= @@ -450,6 +452,8 @@ golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..33ec1be --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,324 @@ +// Copyright (c) 2021-2024, Ludvig Lundgren and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package auth + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/argon2id" + "github.com/autobrr/autobrr/pkg/errors" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/sessions" + "github.com/rs/zerolog" + "golang.org/x/oauth2" +) + +type OIDCConfig struct { + Enabled bool + Issuer string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string +} + +type OIDCHandler struct { + config *OIDCConfig + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauthConfig *oauth2.Config + log zerolog.Logger + cookieStore *sessions.CookieStore +} + +func NewOIDCHandler(cfg *domain.Config, log zerolog.Logger) (*OIDCHandler, error) { + log.Debug(). + Bool("oidc_enabled", cfg.OIDCEnabled). + Str("oidc_issuer", cfg.OIDCIssuer). + Str("oidc_client_id", cfg.OIDCClientID). + Str("oidc_redirect_url", cfg.OIDCRedirectURL). + Str("oidc_scopes", cfg.OIDCScopes). + Msg("initializing OIDC handler with config") + + //if !cfg.OIDCEnabled { + // log.Debug().Msg("OIDC is not enabled, returning nil handler") + // return nil, nil + //} + + if cfg.OIDCIssuer == "" { + log.Error().Msg("OIDC issuer is empty") + return nil, errors.New("OIDC issuer is required") + } + + if cfg.OIDCClientID == "" { + log.Error().Msg("OIDC client ID is empty") + return nil, errors.New("OIDC client ID is required") + } + + if cfg.OIDCClientSecret == "" { + log.Error().Msg("OIDC client secret is empty") + return nil, errors.New("OIDC client secret is required") + } + + if cfg.OIDCRedirectURL == "" { + log.Error().Msg("OIDC redirect URL is empty") + return nil, errors.New("OIDC redirect URL is required") + } + + scopes := []string{"openid", "profile", "email"} + + issuer := cfg.OIDCIssuer + ctx := context.Background() + + // First try with original issuer + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + // If failed and issuer ends with slash, try without + if strings.HasSuffix(issuer, "/") { + withoutSlash := strings.TrimRight(issuer, "/") + log.Debug(). + Str("original_issuer", issuer). + Str("retry_issuer", withoutSlash). + Msg("retrying OIDC provider initialization without trailing slash") + + provider, err = oidc.NewProvider(ctx, withoutSlash) + } else { + // If failed and issuer doesn't end with slash, try with + withSlash := issuer + "/" + log.Debug().Str("original_issuer", issuer).Str("retry_issuer", withSlash).Msg("retrying OIDC provider initialization with trailing slash") + + provider, err = oidc.NewProvider(ctx, withSlash) + } + + if err != nil { + log.Error().Err(err).Msg("failed to initialize OIDC provider") + return nil, errors.Wrap(err, "failed to initialize OIDC provider") + } + } + + var claims struct { + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserURL string `json:"userinfo_endpoint"` + } + if err := provider.Claims(&claims); err != nil { + log.Warn().Err(err).Msg("failed to parse provider claims for endpoints") + } else { + log.Debug().Str("authorization_endpoint", claims.AuthURL).Str("token_endpoint", claims.TokenURL).Str("jwks_uri", claims.JWKSURL).Str("userinfo_endpoint", claims.UserURL).Msg("discovered OIDC provider endpoints") + } + + oidcConfig := &oidc.Config{ + ClientID: cfg.OIDCClientID, + } + + stateSecret := generateRandomState() + + handler := &OIDCHandler{ + log: log, + config: &OIDCConfig{ + Enabled: cfg.OIDCEnabled, + Issuer: cfg.OIDCIssuer, + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + Scopes: scopes, + }, + provider: provider, + verifier: provider.Verifier(oidcConfig), + oauthConfig: &oauth2.Config{ + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + Endpoint: provider.Endpoint(), + Scopes: scopes, + }, + cookieStore: sessions.NewCookieStore([]byte(stateSecret)), + } + + log.Debug().Msg("OIDC handler initialized successfully") + return handler, nil +} + +func (h *OIDCHandler) GetConfig() *OIDCConfig { + if h == nil { + return &OIDCConfig{ + Enabled: false, + } + } + h.log.Debug().Bool("enabled", h.config.Enabled).Str("issuer", h.config.Issuer).Msg("returning OIDC config") + return h.config +} + +func (h *OIDCHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { + session, err := h.cookieStore.Get(r, "user_session") + if err != nil { + h.log.Error().Err(err).Msg("failed to get user session") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if session.Values["authenticated"] == true { + h.log.Debug().Msg("user already has valid session, skipping OIDC login") + http.Redirect(w, r, "/", http.StatusFound) + return + } + + state := generateRandomState() + h.SetStateCookie(w, r, state) + + authURL := h.oauthConfig.AuthCodeURL(state) + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) (string, error) { + h.log.Debug().Msg("handling OIDC callback") + + // get state from session + session, err := h.cookieStore.Get(r, "oidc_state") + if err != nil { + h.log.Error().Err(err).Msg("state session not found") + return "", errors.New("state session not found") + } + + expectedState, ok := session.Values["state"].(string) + if !ok { + h.log.Error().Msg("state not found in session") + return "", errors.New("state not found in session") + } + + if r.URL.Query().Get("state") != expectedState { + h.log.Error().Str("expected", expectedState).Str("got", r.URL.Query().Get("state")).Msg("state did not match") + return "", errors.New("state did not match") + } + + // clear the state session after use + session.Options.MaxAge = -1 + if err := session.Save(r, w); err != nil { + h.log.Error().Err(err).Msg("failed to clear state session") + } + + code := r.URL.Query().Get("code") + if code == "" { + h.log.Error().Msg("authorization code is missing from callback request") + return "", errors.New("authorization code is missing from callback request") + } + + oauth2Token, err := h.oauthConfig.Exchange(r.Context(), code) + if err != nil { + h.log.Error().Err(err).Msg("failed to exchange token") + return "", errors.Wrap(err, "failed to exchange token") + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + h.log.Error().Msg("no id_token found in oauth2 token") + return "", errors.New("no id_token found in oauth2 token") + } + + idToken, err := h.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + h.log.Error().Err(err).Msg("failed to verify ID Token") + return "", errors.Wrap(err, "failed to verify ID Token") + } + + var claims struct { + Email string `json:"email"` + Username string `json:"preferred_username"` + Name string `json:"name"` + GivenName string `json:"given_name"` + Nickname string `json:"nickname"` + Sub string `json:"sub"` + } + if err := idToken.Claims(&claims); err != nil { + h.log.Error().Err(err).Msg("failed to parse claims") + return "", errors.Wrap(err, "failed to parse claims") + } + + // Try different claims in order of preference for username + // This is solely used for frontend display + username := claims.Username + if username == "" { + if claims.Nickname != "" { + username = claims.Nickname + } else if claims.Name != "" { + username = claims.Name + } else if claims.Email != "" { + username = claims.Email + } else if claims.Sub != "" { + username = claims.Sub + } else { + username = "oidc_user" + } + } + + h.log.Debug().Str("username", username).Str("email", claims.Email).Str("nickname", claims.Nickname).Str("name", claims.Name).Str("sub", claims.Sub).Msg("successfully processed OIDC claims") + + return username, nil +} + +func generateRandomState() string { + b, err := argon2id.GenerateRandomBytes(32) + if err != nil { + b = make([]byte, 32) + rand.Read(b) + } + return fmt.Sprintf("%x", b) +} + +func (h *OIDCHandler) GetAuthorizationURL() string { + if h == nil { + return "" + } + state := generateRandomState() + return h.oauthConfig.AuthCodeURL(state) +} + +type GetConfigResponse struct { + Enabled bool `json:"enabled"` + AuthorizationURL string `json:"authorizationUrl"` + State string `json:"state"` +} + +func (h *OIDCHandler) GetConfigResponse() GetConfigResponse { + if h == nil { + return GetConfigResponse{ + Enabled: false, + } + } + + state := generateRandomState() + authURL := h.oauthConfig.AuthCodeURL(state) + + h.log.Debug().Bool("enabled", h.config.Enabled).Str("authorization_url", authURL).Str("state", state).Msg("returning OIDC config response") + + return GetConfigResponse{ + Enabled: h.config.Enabled, + AuthorizationURL: authURL, + State: state, + } +} + +// SetStateCookie sets a secure cookie containing the OIDC state parameter. +// The state parameter is verified when the OAuth provider redirects back to our callback. +// Short expiration ensures the authentication flow must be completed in a reasonable timeframe. +func (h *OIDCHandler) SetStateCookie(w http.ResponseWriter, r *http.Request, state string) { + session, _ := h.cookieStore.New(r, "oidc_state") + session.Values["state"] = state + session.Options.MaxAge = 300 + session.Options.HttpOnly = true + session.Options.Secure = r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + session.Options.SameSite = http.SameSiteLaxMode + session.Options.Path = "/" + + if err := session.Save(r, w); err != nil { + h.log.Error().Err(err).Msg("failed to save state session") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d7df3ab..ff4dbef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,6 +108,25 @@ sessionSecret = "{{ .sessionSecret }}" # # Default: 6060 #profilingPort = 6060 + +# OpenID Connect Configuration +# +# Enable OIDC authentication +#oidc_enabled = false +# +# OIDC Issuer URL (e.g. https://auth.example.com) +#oidc_issuer = "" +# +# OIDC Client ID +#oidc_client_id = "" +# +# OIDC Client Secret +#oidc_client_secret = "" +# +# OIDC Redirect URL (e.g. http://localhost:7474/api/auth/oidc/callback) +#oidc_redirect_url = "" + +# Custom definitions ` func (c *AppConfig) writeConfig(configPath string, configFile string) error { @@ -365,6 +384,27 @@ func (c *AppConfig) loadFromEnv() { c.Config.ProfilingPort = int(i) } } + + // OIDC Configuration + if v := os.Getenv(prefix + "OIDC_ENABLED"); v != "" { + c.Config.OIDCEnabled = strings.EqualFold(strings.ToLower(v), "true") + } + + if v := os.Getenv(prefix + "OIDC_ISSUER"); v != "" { + c.Config.OIDCIssuer = v + } + + if v := os.Getenv(prefix + "OIDC_CLIENT_ID"); v != "" { + c.Config.OIDCClientID = v + } + + if v := os.Getenv(prefix + "OIDC_CLIENT_SECRET"); v != "" { + c.Config.OIDCClientSecret = v + } + + if v := os.Getenv(prefix + "OIDC_REDIRECT_URL"); v != "" { + c.Config.OIDCRedirectURL = v + } } func validDatabaseType(v string) bool { diff --git a/internal/domain/config.go b/internal/domain/config.go index 26f99de..e61d877 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -29,6 +29,12 @@ type Config struct { ProfilingEnabled bool `toml:"profilingEnabled"` ProfilingHost string `toml:"profilingHost"` ProfilingPort int `toml:"profilingPort"` + OIDCEnabled bool `mapstructure:"oidc_enabled"` + OIDCIssuer string `mapstructure:"oidc_issuer"` + OIDCClientID string `mapstructure:"oidc_client_id"` + OIDCClientSecret string `mapstructure:"oidc_client_secret"` + OIDCRedirectURL string `mapstructure:"oidc_redirect_url"` + OIDCScopes string `mapstructure:"oidc_scopes"` } type ConfigUpdate struct { diff --git a/internal/http/auth.go b/internal/http/auth.go index 07e508c..7a32039 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -6,13 +6,16 @@ package http import ( "context" "encoding/json" + "fmt" "net/http" "time" + "github.com/autobrr/autobrr/internal/auth" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/sessions" "github.com/rs/zerolog" ) @@ -25,16 +28,21 @@ type authService interface { } type authHandler struct { - log zerolog.Logger - encoder encoder - config *domain.Config - service authService - server Server - + log zerolog.Logger + encoder encoder + config *domain.Config + service authService + server Server cookieStore *sessions.CookieStore + oidcHandler *auth.OIDCHandler } func newAuthHandler(encoder encoder, log zerolog.Logger, server Server, config *domain.Config, cookieStore *sessions.CookieStore, service authService) *authHandler { + oidcHandler, err := auth.NewOIDCHandler(config, log) + if err != nil { + log.Error().Err(err).Msg("failed to initialize OIDC handler") + } + return &authHandler{ log: log, encoder: encoder, @@ -42,6 +50,7 @@ func newAuthHandler(encoder encoder, log zerolog.Logger, server Server, config * service: service, cookieStore: cookieStore, server: server, + oidcHandler: oidcHandler, } } @@ -50,6 +59,12 @@ func (h authHandler) Routes(r chi.Router) { r.Post("/onboard", h.onboard) r.Get("/onboard", h.canOnboard) + r.Route("/oidc", func(r chi.Router) { + r.Use(middleware.ThrottleBacklog(1, 1, time.Second)) + r.Get("/config", h.getOIDCConfig) + r.Get("/callback", h.handleOIDCCallback) + }) + // Group for authenticated routes r.Group(func(r chi.Router) { r.Use(h.server.IsAuthenticated) @@ -84,6 +99,7 @@ func (h authHandler) login(w http.ResponseWriter, r *http.Request) { // Set user as authenticated session.Values["authenticated"] = true session.Values["created"] = time.Now().Unix() + session.Values["auth_method"] = "password" // Set cookie options session.Options.HttpOnly = true @@ -187,9 +203,16 @@ func (h authHandler) onboardEligible(ctx context.Context) (int, error) { // If there is a valid session return OK, otherwise the middleware returns early with a 401 func (h authHandler) validate(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*sessions.Session) - if session != nil { + if session != nil && session.Values["username"] != nil { h.log.Debug().Msgf("found user session: %+v", session) + // Return username if available in session + response := map[string]interface{}{ + "username": session.Values["username"], + "auth_method": session.Values["auth_method"], + } + h.encoder.StatusResponse(w, http.StatusOK, response) + return } // send empty response as ok h.encoder.NoContent(w) @@ -212,3 +235,93 @@ func (h authHandler) updateUser(w http.ResponseWriter, r *http.Request) { // send response as ok h.encoder.StatusResponseMessage(w, http.StatusOK, "user successfully updated") } + +func (h authHandler) getOIDCConfig(w http.ResponseWriter, r *http.Request) { + h.log.Debug().Msg("getting OIDC config") + + if h.oidcHandler == nil { + h.log.Debug().Msg("OIDC handler is nil, returning disabled config") + h.encoder.StatusResponse(w, http.StatusOK, auth.GetConfigResponse{ + Enabled: false, + }) + return + } + + // Get the config first + config := h.oidcHandler.GetConfigResponse() + + // Check for existing session + session, err := h.cookieStore.Get(r, "user_session") + if err == nil && session.Values["authenticated"] == true { + h.log.Debug().Msg("user already has valid session, skipping OIDC state cookie") + // Still return enabled=true, just don't set the cookie + h.encoder.StatusResponse(w, http.StatusOK, config) + return + } + + h.log.Debug().Bool("enabled", config.Enabled).Str("authorization_url", config.AuthorizationURL).Str("state", config.State).Msg("returning OIDC config") + + // Only set state cookie if user is not already authenticated + h.oidcHandler.SetStateCookie(w, r, config.State) + + h.encoder.StatusResponse(w, http.StatusOK, config) +} + +func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if h.oidcHandler == nil { + h.encoder.StatusError(w, http.StatusServiceUnavailable, errors.New("OIDC not configured")) + return + } + + username, err := h.oidcHandler.HandleCallback(w, r) + if err != nil { + h.encoder.StatusError(w, http.StatusUnauthorized, errors.Wrap(err, "OIDC authentication failed")) + return + } + + // Create new session + session, err := h.cookieStore.Get(r, "user_session") + if err != nil { + h.log.Error().Err(err).Msgf("Auth: Failed to create cookies with attempt username: [%s] ip: %s", username, r.RemoteAddr) + h.encoder.StatusError(w, http.StatusInternalServerError, errors.New("could not create cookies")) + return + } + + // Set user as authenticated + session.Values["authenticated"] = true + session.Values["created"] = time.Now().Unix() + session.Values["username"] = username + session.Values["auth_method"] = "oidc" + + // Set cookie options + session.Options.HttpOnly = true + session.Options.SameSite = http.SameSiteLaxMode + session.Options.Path = h.config.BaseURL + + // If forwarded protocol is https then set cookie secure + if r.Header.Get("X-Forwarded-Proto") == "https" { + session.Options.Secure = true + session.Options.SameSite = http.SameSiteStrictMode + } + + if err := session.Save(r, w); err != nil { + h.encoder.StatusError(w, http.StatusInternalServerError, errors.Wrap(err, "could not save session")) + return + } + + // Redirect to the frontend + frontendURL := h.config.BaseURL + if frontendURL == "/" { + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + frontendURL = fmt.Sprintf("%s://%s", proto, host) + } + } + + h.log.Debug().Str("redirect_url", frontendURL).Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).Str("x_forwarded_host", r.Header.Get("X-Forwarded-Host")).Str("host", r.Host).Msg("redirecting to frontend after OIDC callback") + + http.Redirect(w, r, frontendURL, http.StatusFound) +} diff --git a/pkg/argon2id/argon2id.go b/pkg/argon2id/argon2id.go index 05c365a..9015f6d 100644 --- a/pkg/argon2id/argon2id.go +++ b/pkg/argon2id/argon2id.go @@ -79,7 +79,7 @@ type Params struct { // // $argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG func CreateHash(password string, params *Params) (hash string, err error) { - salt, err := generateRandomBytes(params.SaltLength) + salt, err := GenerateRandomBytes(params.SaltLength) if err != nil { return "", err } @@ -125,7 +125,7 @@ func CheckHash(password, hash string) (match bool, params *Params, err error) { return false, params, nil } -func generateRandomBytes(n uint32) ([]byte, error) { +func GenerateRandomBytes(n uint32) ([]byte, error) { b := make([]byte, n) _, err := rand.Read(b) if err != nil { diff --git a/web/package.json b/web/package.json index 2c25566..4b471f9 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,9 @@ } }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.1", + "@fortawesome/free-brands-svg-icons": "^6.7.1", + "@fortawesome/react-fontawesome": "^0.2.2", "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", "@hookform/error-message": "^2.0.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2368081..a718b49 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -13,6 +13,15 @@ importers: .: dependencies: + '@fortawesome/fontawesome-svg-core': + specifier: ^6.7.1 + version: 6.7.1 + '@fortawesome/free-brands-svg-icons': + specifier: ^6.7.1 + version: 6.7.1 + '@fortawesome/react-fontawesome': + specifier: ^0.2.2 + version: 0.2.2(@fortawesome/fontawesome-svg-core@6.7.1)(react@18.3.1) '@headlessui/react': specifier: ^2.2.0 version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -923,6 +932,24 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@fortawesome/fontawesome-common-types@6.7.1': + resolution: {integrity: sha512-gbDz3TwRrIPT3i0cDfujhshnXO9z03IT1UKRIVi/VEjpNHtSBIP2o5XSm+e816FzzCFEzAxPw09Z13n20PaQJQ==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-svg-core@6.7.1': + resolution: {integrity: sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==} + engines: {node: '>=6'} + + '@fortawesome/free-brands-svg-icons@6.7.1': + resolution: {integrity: sha512-nJR76eqPzCnMyhbiGf6X0aclDirZriTPRcFm1YFvuupyJOGwlNF022w3YBqu+yrHRhnKRpzFX+8wJKqiIjWZkA==} + engines: {node: '>=6'} + + '@fortawesome/react-fontawesome@0.2.2': + resolution: {integrity: sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==} + peerDependencies: + '@fortawesome/fontawesome-svg-core': ~1 || ~6 + react: ^18.3.1 + '@headlessui/react@2.2.0': resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==} engines: {node: '>=10'} @@ -3402,6 +3429,7 @@ packages: workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@7.0.0: resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==} @@ -4347,6 +4375,22 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@fortawesome/fontawesome-common-types@6.7.1': {} + + '@fortawesome/fontawesome-svg-core@6.7.1': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.1 + + '@fortawesome/free-brands-svg-icons@6.7.1': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.1 + + '@fortawesome/react-fontawesome@0.2.2(@fortawesome/fontawesome-svg-core@6.7.1)(react@18.3.1)': + dependencies: + '@fortawesome/fontawesome-svg-core': 6.7.1 + prop-types: 15.8.1 + react: 18.3.1 + '@headlessui/react@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 1d51b39..770ce6d 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -5,11 +5,15 @@ import { baseUrl, sseBaseUrl } from "@utils"; import { GithubRelease } from "@app/types/Update"; -import { AuthContext } from "@utils/Context"; +import { AuthContext, AuthInfo } from "@utils/Context"; import { ColumnFilter } from "@tanstack/react-table"; type RequestBody = BodyInit | object | Record | null; type Primitive = string | number | boolean | symbol | undefined; +type ValidateResponse = { + username?: AuthInfo['username']; + auth_method?: AuthInfo['authMethod']; +} interface HttpConfig { /** @@ -239,19 +243,33 @@ const appClient = { }) }; + export const APIClient = { auth: { login: (username: string, password: string) => appClient.Post("api/auth/login", { body: { username, password } }), logout: () => appClient.Post("api/auth/logout"), - validate: () => appClient.Get("api/auth/validate"), + validate: async (): Promise => { + const response = await appClient.Get("api/auth/validate"); + return response; + }, onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", { body: { username, password } }), canOnboard: () => appClient.Get("api/auth/onboard"), updateUser: (req: UserUpdate) => appClient.Patch(`api/auth/user/${req.username_current}`, - { body: req }) + { body: req }), + getOIDCConfig: async () => { + try { + return await appClient.Get<{ enabled: boolean; authorizationUrl: string; state: string }>("api/auth/oidc/config"); + } catch (error: unknown) { + if (error instanceof Error && error.message?.includes('404')) { + return { enabled: false, authorizationUrl: '', state: '' }; + } + throw error; + } + }, }, actions: { create: (action: Action) => appClient.Post("api/actions", { diff --git a/web/src/components/header/RightNav.tsx b/web/src/components/header/RightNav.tsx index 2155adf..a4ebbfa 100644 --- a/web/src/components/header/RightNav.tsx +++ b/web/src/components/header/RightNav.tsx @@ -6,6 +6,8 @@ import { Fragment } from "react"; import { UserIcon } from "@heroicons/react/24/solid"; import { Menu, MenuButton, MenuItem, MenuItems, Transition } from "@headlessui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faOpenid } from "@fortawesome/free-brands-svg-icons"; import { classNames } from "@utils"; @@ -18,6 +20,8 @@ import { AuthContext, SettingsContext } from "@utils/Context"; export const RightNav = (props: RightNavProps) => { const [settings, setSettings] = SettingsContext.use(); + const auth = AuthContext.get(); + const toggleTheme = () => { setSettings(prevState => ({ ...prevState, @@ -56,12 +60,22 @@ export const RightNav = (props: RightNavProps) => { Open user menu for{" "} - {AuthContext.get().username} + + {auth.username} + {auth.authMethod === 'oidc' ? ( + -