diff --git a/.gitignore b/.gitignore index ace40cd7d..b0dfef689 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist *.swp .tags* *.test +data/ diff --git a/README.md b/README.md index 508e1755a..7c3c51788 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,50 @@ password can then be changed from the web interface | `GONIC_MULTI_VALUE_ALBUM_ARTIST` | `-multi-value-album-artist` | **optional** setting for multi-valued album artist tags when scanning ([see more](#multi-valued-tags-v016)) | | `GONIC_TRANSCODE_CACHE_SIZE` | `-transcode-cache-size` | **optional** size of the transcode cache in MB (0 = no limit) | | `GONIC_TRANSCODE_EJECT_INTERVAL` | `-transcode-eject-interval` | **optional** interval (in minutes) to eject transcode cache (0 = never) | +| `GONIC_AUTH_METHOD` | `-auth-method` | **optional** authentication method: `password` (default), `oidc`, or `oidc-forward` ([see more](#oidc-authentication)) | +| `GONIC_OIDC_ISSUER_URL` | `-oidc-issuer-url` | **optional** OIDC issuer URL for token authentication ([see more](#oidc-authentication)) | +| `GONIC_OIDC_CLIENT_ID` | `-oidc-client-id` | **optional** OIDC client ID for token validation ([see more](#oidc-authentication)) | +| `GONIC_OIDC_CLIENT_SECRET` | `-oidc-client-secret` | **optional** OIDC client secret for token exchange ([see more](#oidc-authentication)) | +| `GONIC_OIDC_CLIENT_SECRET_FILE` | `-oidc-client-secret-file` | **optional** path to file containing OIDC client secret ([see more](#oidc-authentication)) | +| `GONIC_OIDC_FORWARD_HEADER` | `-oidc-forward-header` | **optional** header name containing OIDC token for oidc-forward method (default: `Authorization`) ([see more](#oidc-authentication)) | +| `GONIC_OIDC_ADMIN_ROLE` | `-oidc-admin-role` | **optional** role name for admin users in OIDC token roles claim (default: `gonic-admin`) ([see more](#oidc-authentication)) | | `GONIC_EXPVAR` | `-expvar` | **optional** enable the /debug/vars endpoint (exposes useful debugging attributes as well as database stats) | +## oidc authentication + +gonic supports OpenID Connect (OIDC) authentication as an alternative to password-based authentication. This allows integration with identity providers like Keycloak, Auth0, Okta, or any OIDC-compliant provider. + +### authentication methods + +| method | description | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `password` | **default** - traditional username/password authentication | +| `oidc` | full OIDC flow with authorization code exchange - users are redirected to identity provider for login, then redirected back to gonic with an authorization code | +| `oidc-forward` | for use behind an authenticating proxy - expects a valid JWT token in the specified header (useful with [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) or similar) | + +### configuration example + +```bash +# basic oidc setup +export GONIC_AUTH_METHOD="oidc" +export GONIC_OIDC_ISSUER_URL="https://your-oidc-provider.com" +export GONIC_OIDC_CLIENT_ID="gonic-client" +export GONIC_OIDC_CLIENT_SECRET="your-client-secret" + +# or for proxy-based authentication +export GONIC_AUTH_METHOD="oidc-forward" +export GONIC_OIDC_ISSUER_URL="https://your-oidc-provider.com" +export GONIC_OIDC_FORWARD_HEADER="X-Auth-Credentials" # header containing JWT token +``` + +### user management + +when using OIDC authentication: + +- users are automatically created on first login using information from the OIDC token +- admin privileges are determined by the presence of the configured admin role in the token's roles claim +- user information is updated from the token on each login + ## multi valued tags (v0.16+) gonic can support potentially multi valued tags like `genres`, `artists`, and `albumartists`. in both cases gonic will individual entries in its database for each. diff --git a/auth/oidc.go b/auth/oidc.go new file mode 100644 index 000000000..c99deba80 --- /dev/null +++ b/auth/oidc.go @@ -0,0 +1,532 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/sessions" + + "go.senan.xyz/gonic/db" + "go.senan.xyz/gonic/handlerutil" +) + +// Global OIDC configuration for validation +var oidcConfig OIDCConfigOptions + +// OIDC discovery and key structures +type OIDCDiscoveryConfig struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +type JWKSResponse struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` +} + +type OIDCConfig struct { + Issuer string + ClientID string + ClientSecret string + JwksURI string + Keys map[string]*rsa.PublicKey +} + +type AuthMethod string + +type OIDCConfigOptions struct { + Issuer string + ClientID string + ClientSecret string // only for 'oidc' + Keys map[string]*rsa.PublicKey + AuthEndpoint string + TokenEndpoint string + HeaderName string // only for 'oidc-forward' + AdminRole string +} + +type FullOIDCConfig struct { + *OIDCConfig + AuthorizationEndpoint string + TokenEndpoint string + AuthMethod AuthMethod + HeaderName string +} + +// The extra JWT claims understood by gonic +type CustomClaims struct { + jwt.RegisteredClaims + Name string `json:"name,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +type OIDCTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +func SetOIDCConfig(config OIDCConfigOptions) { + oidcConfig = config +} + +func GetOIDCHeader() string { + return oidcConfig.HeaderName +} + +func GetOIDCAuthEndpoint() string { + return oidcConfig.AuthEndpoint +} + +func DebugPrintJWT(tokenString string) { + token, _, err := jwt.NewParser().ParseUnverified(tokenString, &CustomClaims{}) + if err != nil { + log.Printf("JWT parsing error: %v", err) + return + } + + claims, ok := token.Claims.(*CustomClaims) + if !ok { + log.Printf("JWT claims parsing failed") + return + } + + debugPrintJWTHeader(token) + debugPrintJWTClaims(claims) +} + +func debugPrintJWTHeader(token *jwt.Token) { + kid := "" + if token.Header["kid"] != nil { + kid = token.Header["kid"].(string) + } + + log.Printf("JWT Header: alg=%s, kid=%s, typ=%s", + token.Header["alg"], kid, token.Header["typ"]) +} + +func debugPrintJWTClaims(claims *CustomClaims) { + var exp, iat int64 + if claims.ExpiresAt != nil { + exp = claims.ExpiresAt.Unix() + } + if claims.IssuedAt != nil { + iat = claims.IssuedAt.Unix() + } + + log.Printf("JWT Claims: iss=%s, sub=%s, aud=%v, exp=%d, iat=%d", + claims.Issuer, claims.Subject, claims.Audience, exp, iat) + if claims.Name != "" { + log.Printf("JWT Name: %s", claims.Name) + } + if len(claims.Roles) > 0 { + log.Printf("JWT Roles: %v", claims.Roles) + } +} + +func ValidateJWTToken(tokenString string, issuer, clientID string, keys map[string]*rsa.PublicKey) (*CustomClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + kidInterface, ok := token.Header["kid"] + if !ok { + return nil, fmt.Errorf("no kid found in token header") + } + kid, ok := kidInterface.(string) + if !ok { + return nil, fmt.Errorf("kid is not a string") + } + rsaKey, exists := keys[kid] + if !exists { + return nil, fmt.Errorf("key ID %q not found in JWKS", kid) + } + + return rsaKey, nil + }, jwt.WithValidMethods([]string{"RS256"}), + jwt.WithIssuer(issuer), + jwt.WithAudience(clientID)) + + if err != nil { + return nil, fmt.Errorf("token validation failed: %w", err) + } + + claims, ok := token.Claims.(*CustomClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token claims") + } + + return claims, nil +} + +func ValidateIncomingJWT(tokenString string) (*CustomClaims, error) { + if oidcConfig.Issuer == "" || oidcConfig.ClientID == "" || oidcConfig.Keys == nil { + return nil, fmt.Errorf("OIDC not configured") + } + return ValidateJWTToken(tokenString, oidcConfig.Issuer, oidcConfig.ClientID, oidcConfig.Keys) +} + +func CheckUserIsAdmin(claims *CustomClaims) bool { + if oidcConfig.AdminRole == "" || len(claims.Roles) == 0 { + return false + } + + for _, role := range claims.Roles { + if role == oidcConfig.AdminRole { + return true + } + } + + return false +} + +func BuildOIDCAuthURL(authEndpoint string, r *http.Request) string { + authURL, err := url.Parse(authEndpoint) + if err != nil { + log.Printf("Failed to parse auth endpoint: %v", err) + return authEndpoint + } + + baseURL := handlerutil.BaseURL(r) + redirectURI := baseURL + "/admin/oidc/callback" + + state, err := GenerateRandomState() + if err != nil { + log.Printf("Failed to generate random state: %v", err) + state = "" + } + + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", oidcConfig.ClientID) + params.Set("redirect_uri", redirectURI) + params.Set("scope", "openid profile") + params.Set("state", state) + + authURL.RawQuery = params.Encode() + + log.Printf("Built OIDC auth URL: %s", authURL.String()) + return authURL.String() +} + +func GenerateRandomPassword() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random password: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +func GenerateRandomState() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("error generating random state: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +func ExchangeCodeForTokens(r *http.Request, code string) (*OIDCTokenResponse, error) { + baseURL := handlerutil.BaseURL(r) + redirectURI := baseURL + "/admin/oidc/callback" + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("client_id", oidcConfig.ClientID) + data.Set("client_secret", oidcConfig.ClientSecret) + + req, err := http.NewRequest("POST", oidcConfig.TokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("creating token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("making token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp OIDCTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + + return &tokenResp, nil +} + +func BuildRedirectURI(r *http.Request) string { + baseURL := handlerutil.BaseURL(r) + return baseURL + "/admin/oidc/callback" +} + +func HandleOIDCLogin(dbc *db.DB, sess *sessions.Session, claims *CustomClaims) (*db.User, error) { + user := &db.User{} + err := dbc.Where("oidc_subject = ?", claims.Subject).First(user).Error + if err != nil { + user, err = createOIDCUser(dbc, claims) + if err != nil { + return nil, err + } + } else { + err = updateOIDCUserAdmin(dbc, user, claims) + if err != nil { + return nil, err + } + } + + sess.Values["user"] = user.ID + return user, nil +} + +func createOIDCUser(dbc *db.DB, claims *CustomClaims) (*db.User, error) { + isAdmin := CheckUserIsAdmin(claims) + + password, err := GenerateRandomPassword() + if err != nil { + return nil, fmt.Errorf("error generating random password: %w", err) + } + + name := claims.Name + if name == "" { + name = claims.Subject + } + user := &db.User{ + Name: name, + OIDCSubject: claims.Subject, + Password: password, + IsAdmin: isAdmin, + } + if err := dbc.Create(user).Error; err != nil { + return nil, fmt.Errorf("error creating user: %w", err) + } + + log.Printf("Created new OIDC user: %s (admin: %t)", user.Name, user.IsAdmin) + return user, nil +} + +func updateOIDCUserAdmin(dbc *db.DB, user *db.User, claims *CustomClaims) error { + isAdmin := CheckUserIsAdmin(claims) + + if user.IsAdmin != isAdmin { + user.IsAdmin = isAdmin + if err := dbc.Save(user).Error; err != nil { + return fmt.Errorf("error updating user: %w", err) + } + log.Printf("Updated OIDC user: %s (admin: %t)", user.Name, user.IsAdmin) + } + return nil +} + +func ValidateOIDCIssuer(issuerURL string) (*OIDCDiscoveryConfig, error) { + if issuerURL == "" { + return nil, nil + } + + discoveryURL := strings.TrimSuffix(issuerURL, "/") + "/.well-known/openid-configuration" + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request to OIDC discovery URL: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("connecting to OIDC discovery URL %q: %w", discoveryURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC discovery URL %q returned status %d", discoveryURL, resp.StatusCode) + } + + var config OIDCDiscoveryConfig + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + return nil, fmt.Errorf("parsing OIDC discovery response: %w", err) + } + + if config.Issuer == "" || config.JwksURI == "" { + return nil, fmt.Errorf("OIDC discovery response missing required fields (issuer or jwks_uri)") + } + + return &config, nil +} + +func ParseJWK(jwk JWK) (*rsa.PublicKey, error) { + if jwk.Kty != "RSA" { + return nil, fmt.Errorf("unsupported key type: %s", jwk.Kty) + } + + // Decode the modulus (n) + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("failed to decode modulus: %w", err) + } + n := new(big.Int).SetBytes(nBytes) + + // Decode the exponent (e) + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("failed to decode exponent: %w", err) + } + e := new(big.Int).SetBytes(eBytes) + + return &rsa.PublicKey{ + N: n, + E: int(e.Int64()), + }, nil +} + +func DownloadJWKS(jwksURI string) (map[string]*rsa.PublicKey, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", jwksURI, nil) + if err != nil { + return nil, fmt.Errorf("creating request to JWKS URI: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading JWKS from %q: %w", jwksURI, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS URI %q returned status %d", jwksURI, resp.StatusCode) + } + + var jwksResp JWKSResponse + if err := json.NewDecoder(resp.Body).Decode(&jwksResp); err != nil { + return nil, fmt.Errorf("parsing JWKS response: %w", err) + } + + keys := make(map[string]*rsa.PublicKey) + for _, jwk := range jwksResp.Keys { + if jwk.Use == "sig" || jwk.Use == "" { // Only use signing keys or unspecified + pubKey, err := ParseJWK(jwk) + if err != nil { + log.Printf("warning: failed to parse JWK with kid %q: %v", jwk.Kid, err) + continue + } + keys[jwk.Kid] = pubKey + } + } + + if len(keys) == 0 { + return nil, fmt.Errorf("no valid RSA keys found in JWKS") + } + + return keys, nil +} + +func ValidateOIDCConfig(issuerURL, clientID, clientSecret, clientSecretFile, headerName string, authMethod AuthMethod) (*FullOIDCConfig, error) { + switch authMethod { + case "password": + return nil, nil + case "oidc": + // oidc method requires issuer URL, client ID, and client secret + if issuerURL == "" { + return nil, fmt.Errorf("oidc-issuer-url is required when auth-method is 'oidc'") + } + if clientID == "" { + return nil, fmt.Errorf("oidc-client-id is required when auth-method is 'oidc'") + } + if clientSecret == "" && clientSecretFile == "" { + return nil, fmt.Errorf("oidc-client-secret or oidc-client-secret-file is required when auth-method is 'oidc'") + } + case "oidc-forward": + // oidc-forward method requires issuer URL, client ID, and header name + if issuerURL == "" { + return nil, fmt.Errorf("oidc-issuer-url is required when auth-method is 'oidc-forward'") + } + if clientID == "" { + return nil, fmt.Errorf("oidc-client-id is required when auth-method is 'oidc-forward'") + } + if headerName == "" { + return nil, fmt.Errorf("oidc-forward-header is required when auth-method is 'oidc-forward'") + } + } + + var finalClientSecret string + if clientSecret != "" && clientSecretFile != "" { + return nil, fmt.Errorf("cannot specify both oidc-client-secret and oidc-client-secret-file") + } + + if clientSecret != "" { + finalClientSecret = clientSecret + } else if clientSecretFile != "" { + secretBytes, err := os.ReadFile(clientSecretFile) + if err != nil { + return nil, fmt.Errorf("reading client secret file %q: %w", clientSecretFile, err) + } + finalClientSecret = strings.TrimSpace(string(secretBytes)) + if finalClientSecret == "" { + return nil, fmt.Errorf("client secret file %q is empty", clientSecretFile) + } + } + + discoveryConfig, err := ValidateOIDCIssuer(issuerURL) + if err != nil { + return nil, fmt.Errorf("validating OIDC issuer: %w", err) + } + + keys, err := DownloadJWKS(discoveryConfig.JwksURI) + if err != nil { + return nil, fmt.Errorf("downloading JWKS: %w", err) + } + + return &FullOIDCConfig{ + OIDCConfig: &OIDCConfig{ + Issuer: discoveryConfig.Issuer, + ClientID: clientID, + ClientSecret: finalClientSecret, + JwksURI: discoveryConfig.JwksURI, + Keys: keys, + }, + AuthorizationEndpoint: discoveryConfig.AuthorizationEndpoint, + TokenEndpoint: discoveryConfig.TokenEndpoint, + AuthMethod: authMethod, + HeaderName: headerName, + }, nil +} diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 0d0e50995..dc10699b4 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -32,6 +32,7 @@ import ( "go.senan.xyz/flagconf" "go.senan.xyz/gonic" + "go.senan.xyz/gonic/auth" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/infocache/albuminfocache" @@ -50,6 +51,27 @@ import ( "go.senan.xyz/gonic/transcode" ) +type AuthMethod string + +const ( + AuthMethodPassword AuthMethod = "password" + AuthMethodOIDC AuthMethod = "oidc" + AuthMethodOIDCForward AuthMethod = "oidc-forward" +) + +func validateAuthMethod(method string) (AuthMethod, error) { + switch method { + case "password": + return AuthMethodPassword, nil + case "oidc": + return AuthMethodOIDC, nil + case "oidc-forward": + return AuthMethodOIDCForward, nil + default: + return "", fmt.Errorf("invalid auth method %q, must be one of: password, oidc, oidc-forward", method) + } +} + func main() { confListenAddr := flag.String("listen-addr", "0.0.0.0:4747", "listen address (optional)") @@ -76,6 +98,7 @@ func main() { confJukeboxMPVExtraArgs := flag.String("jukebox-mpv-extra-args", "", "extra command line arguments to pass to the jukebox mpv daemon (optional)") confProxyPrefix := flag.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)") + confDomain := flag.String("domain", "", "base domain to use for URL generation. overrides host header detection. eg 'https://music.example.com' (optional)") confHTTPLog := flag.Bool("http-log", true, "http request logging (optional)") confShowVersion := flag.Bool("version", false, "show gonic version") @@ -96,6 +119,14 @@ func main() { confTranscodeCacheSize := flag.Int("transcode-cache-size", 0, "size of the transcode cache in MB (0 = no limit) (optional)") confTranscodeEjectInterval := flag.Int("transcode-eject-interval", 0, "interval (in minutes) to eject transcode cache (0 = never) (optional)") + confAuthMethod := flag.String("auth-method", "password", "Authentication method: 'password', 'oidc', or 'oidc-forward'") + confOIDCIssuerURL := flag.String("oidc-issuer-url", "", "OIDC issuer URL for token authentication (optional)") + confOIDCClientID := flag.String("oidc-client-id", "", "OIDC client ID for token validation (optional)") + confOIDCClientSecret := flag.String("oidc-client-secret", "", "OIDC client secret for token exchange (optional, for dev purposes)") + confOIDCClientSecretFile := flag.String("oidc-client-secret-file", "", "Path to file containing OIDC client secret (optional)") + confOIDCHeader := flag.String("oidc-forward-header", "Authorization", "Header name containing OIDC token for oidc-forward method") + confOIDCAdminRole := flag.String("oidc-admin-role", "gonic-admin", "Role name for admin users in OIDC token roles claim") + flag.Parse() flagconf.ParseEnv() flagconf.ParseConfig(*confConfigPath) @@ -129,6 +160,43 @@ func main() { if *confPlaylistsPath, err = validatePath(*confPlaylistsPath); err != nil { log.Fatalf("checking playlist directory: %v", err) } + authMethod, err := validateAuthMethod(*confAuthMethod) + if err != nil { + log.Fatalf("validating auth method: %v", err) + } + + fullOIDCConfig, err := auth.ValidateOIDCConfig(*confOIDCIssuerURL, *confOIDCClientID, *confOIDCClientSecret, *confOIDCClientSecretFile, *confOIDCHeader, auth.AuthMethod(authMethod)) + if err != nil { + log.Fatalf("validating OIDC configuration: %v", err) + } + ctrladmin.SetAuthMethod(string(authMethod)) + + oidcConfigOptions := auth.OIDCConfigOptions{ + Issuer: fullOIDCConfig.Issuer, + ClientID: fullOIDCConfig.ClientID, + ClientSecret: fullOIDCConfig.ClientSecret, + Keys: fullOIDCConfig.Keys, + AuthEndpoint: fullOIDCConfig.AuthorizationEndpoint, + TokenEndpoint: fullOIDCConfig.TokenEndpoint, + HeaderName: *confOIDCHeader, + AdminRole: *confOIDCAdminRole, + } + auth.SetOIDCConfig(oidcConfigOptions) + + if *confDomain != "" { + domain := *confDomain + + if !strings.Contains(domain, "://") { + log.Fatalf("domain must include a scheme (http:// or https://): %q", domain) + } + + if strings.HasSuffix(domain, "/") { + log.Fatalf("domain must not have a trailing slash: %q", domain) + } + + handlerutil.SetDomainOverride(domain) + log.Printf("Domain override set to: %s", domain) + } cacheDirAudio := path.Join(*confCachePath, "audio") cacheDirCovers := path.Join(*confCachePath, "covers") diff --git a/db/db.go b/db/db.go index f4b9ff7bf..6e5555bf9 100644 --- a/db/db.go +++ b/db/db.go @@ -120,6 +120,18 @@ func (db *DB) GetUserByName(name string) *User { return &user } +func (db *DB) GetUserByOIDCSubject(oidcSubject string) *User { + var user User + err := db. + Where("oidc_subject=?", oidcSubject). + First(&user). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return &user +} + func (db *DB) Begin() *DB { return &DB{DB: db.DB.Begin()} } @@ -302,6 +314,7 @@ type User struct { ListenBrainzToken string `sql:"default: null"` IsAdmin bool `sql:"default: null"` Avatar []byte `sql:"default: null"` + OIDCSubject string `gorm:"unique_index;column:oidc_subject" sql:"default: null"` } type Setting struct { diff --git a/db/migrations.go b/db/migrations.go index 5c37bc0fd..51f2cd9c0 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -80,6 +80,7 @@ func (db *DB) Migrate(ctx MigrationContext) error { construct(ctx, "202505211202", migrateTrackAddIndexOnBrainzID), construct(ctx, "202505262025", migrateAlbumAddIndexOnBrainzID), construct(ctx, "202507062103", migrateAlbumCompilationReleaseType), + construct(ctx, "202507101200", migrateAddOIDCSubject), } return gormigrate. @@ -861,3 +862,9 @@ func migrateAlbumCompilationReleaseType(tx *gorm.DB, _ MigrationContext) error { Album{}, ).Error } + +func migrateAddOIDCSubject(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + User{}, + ).Error +} diff --git a/go.mod b/go.mod index 42957579a..9aa7673d2 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/imdario/mergo v0.3.16 // indirect diff --git a/go.sum b/go.sum index 3ecb1315f..c4c9af3d9 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/handlerutil/handlerutil.go b/handlerutil/handlerutil.go index 8fd27ae1b..6ca47515c 100644 --- a/handlerutil/handlerutil.go +++ b/handlerutil/handlerutil.go @@ -7,6 +7,12 @@ import ( "strings" ) +var domainOverride string + +func SetDomainOverride(domain string) { + domainOverride = domain +} + type Middleware func(http.Handler) http.Handler func Chain(middlewares ...Middleware) Middleware { @@ -63,6 +69,10 @@ func Message(message string) http.Handler { } func BaseURL(r *http.Request) string { + if domainOverride != "" { + return domainOverride + } + var fallbackScheme = "http" if r.TLS != nil { fallbackScheme = "https" diff --git a/server/ctrladmin/adminui/pages/auth_error.tmpl b/server/ctrladmin/adminui/pages/auth_error.tmpl new file mode 100644 index 000000000..9038bb0f0 --- /dev/null +++ b/server/ctrladmin/adminui/pages/auth_error.tmpl @@ -0,0 +1,20 @@ +{{ component "layout" . }} +{{ component "block" (props . + "Icon" "alert-triangle" + "Name" "authentication error" + "Desc" "authentication failed - missing or invalid authorization header" +) }} +
+ Authentication is required to access this page. +
++ This server is configured to use OIDC forward authentication, + which requires a valid JWT token in the Authorization header. +
++ If you believe this is an error, please contact your administrator. +
++ This server uses OIDC authentication. +
++ You will be redirected to the authentication provider when accessing protected pages. +
+ {{ else if eq .Props.AuthMethod "oidc-forward" }} ++ This server uses OIDC forward authentication. +
++ Authentication is handled by an external system. Please ensure your JWT token is included in the Authorization header. +
+ {{ end }} +Current password:
+ {{ if .Props.ShowPlainText }} + +Password is shown in plain text. You can select and copy it manually.
+ {{ else }} + +This password can be used for Subsonic API access.
+ {{ end }} +