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. +

+
+{{ end }} +{{ end }} \ No newline at end of file diff --git a/server/ctrladmin/adminui/pages/home.tmpl b/server/ctrladmin/adminui/pages/home.tmpl index 134f60752..dcd94a3f5 100644 --- a/server/ctrladmin/adminui/pages/home.tmpl +++ b/server/ctrladmin/adminui/pages/home.tmpl @@ -34,7 +34,11 @@
{{ $user.Name }}
{{ $user.CreatedAt | date }}
{{ component "link" (props . "To" (printf "/admin/change_username?user=%s" $user.Name | path)) }}username{{ end }} - {{ component "link" (props . "To" (printf "/admin/change_password?user=%s" $user.Name | path)) }}password{{ end }} + {{ if $user.OIDCSubject }} + {{ component "link" (props . "To" (printf "/admin/reveal_password?user=%s" $user.Name | path)) }}api token{{ end }} + {{ else }} + {{ component "link" (props . "To" (printf "/admin/change_password?user=%s" $user.Name | path)) }}password{{ end }} + {{ end }} {{ component "link" (props . "To" (printf "/admin/change_avatar?user=%s" $user.Name | path)) }}avatar{{ end }} {{ if $user.IsAdmin }}
delete
diff --git a/server/ctrladmin/adminui/pages/login.tmpl b/server/ctrladmin/adminui/pages/login.tmpl index 1ad925329..000294269 100644 --- a/server/ctrladmin/adminui/pages/login.tmpl +++ b/server/ctrladmin/adminui/pages/login.tmpl @@ -1,4 +1,5 @@ {{ component "layout" . }} +{{ if .Props.PasswordAuthEnabled }} {{ component "block" (props . "Icon" "user" "Name" "login" @@ -10,4 +11,29 @@ {{ end }} +{{ else }} +{{ component "block" (props . + "Icon" "lock" + "Name" "authentication disabled" + "Desc" "password authentication is not available on this server" +) }} +
+ {{ if eq .Props.AuthMethod "oidc" }} +

+ 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 }} +
+{{ end }} +{{ end }} {{ end }} diff --git a/server/ctrladmin/adminui/pages/reveal_password.tmpl b/server/ctrladmin/adminui/pages/reveal_password.tmpl new file mode 100644 index 000000000..9a341fef6 --- /dev/null +++ b/server/ctrladmin/adminui/pages/reveal_password.tmpl @@ -0,0 +1,57 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" (printf "password for %s" .SelectedUser.Name) +) }} +
+

Current password:

+ {{ if .Props.ShowPlainText }} + +
+
{{ .SelectedUser.Password }}
+ Hide +
+

Password is shown in plain text. You can select and copy it manually.

+ {{ else }} + +
+ + +
+ + +

This password can be used for Subsonic API access.

+ {{ end }} +
+ + +{{ end }} + +{{ end }} +{{ end }} \ No newline at end of file diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index 063869f64..b6b8eeee1 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -24,6 +24,7 @@ import ( "github.com/sentriz/gormstore" "go.senan.xyz/gonic" + "go.senan.xyz/gonic/auth" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/lastfm" @@ -39,6 +40,15 @@ const ( CtxSession ) +const ( + AuthMethodPassword = "password" + AuthMethodOIDC = "oidc" + AuthMethodOIDCForward = "oidc-forward" +) + +// Global auth method configuration +var authMethod string + type Controller struct { *http.ServeMux @@ -81,6 +91,10 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts // public routes (creates session) c.Handle("/login", baseChain(resp(c.ServeLogin))) c.Handle("/login_do", baseChain(respRaw(c.ServeLoginDo))) + c.Handle("/oidc/callback", baseChain(respRaw(func(w http.ResponseWriter, r *http.Request) { + serveOIDCCallback(dbc, w, r, resolveProxyPath) + }))) + c.Handle("/auth-error", baseChain(resp(c.ServeAuthError))) // user routes (if session is valid) c.Handle("/logout", userChain(respRaw(c.ServeLogout))) @@ -89,6 +103,7 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts c.Handle("/change_username_do", userChain(resp(c.ServeChangeUsernameDo))) c.Handle("/change_password", userChain(resp(c.ServeChangePassword))) c.Handle("/change_password_do", userChain(resp(c.ServeChangePasswordDo))) + c.Handle("/reveal_password", userChain(resp(c.ServeRevealPassword))) c.Handle("/change_avatar", userChain(resp(c.ServeChangeAvatar))) c.Handle("/change_avatar_do", userChain(resp(c.ServeChangeAvatarDo))) c.Handle("/delete_avatar_do", userChain(resp(c.ServeDeleteAvatarDo))) @@ -135,30 +150,102 @@ func withSession(sessDB *gormstore.Store) handlerutil.Middleware { } } +func SetAuthMethod(method string) { + authMethod = method +} + +func GetAuthMethod() string { + return authMethod +} + func withUserSession(dbc *db.DB, resolvePath func(string) string) handlerutil.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // session exists at this point - session := r.Context().Value(CtxSession).(*sessions.Session) - userID, ok := session.Values["user"].(int) - if !ok { - sessAddFlashW(session, []string{"you are not authenticated"}) - sessLogSave(session, w, r) - http.Redirect(w, r, resolvePath("/admin/login"), http.StatusSeeOther) + // Handle different auth methods + switch GetAuthMethod() { + case AuthMethodPassword: + // Password authentication - check session only + session := r.Context().Value(CtxSession).(*sessions.Session) + userID, ok := session.Values["user"].(int) + if !ok { + sessAddFlashW(session, []string{"you are not authenticated"}) + sessLogSave(session, w, r) + http.Redirect(w, r, resolvePath("/admin/login"), http.StatusSeeOther) + return + } + user := dbc.GetUserByID(userID) + if user == nil { + session.Options.MaxAge = -1 + sessLogSave(session, w, r) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + withUser := context.WithValue(r.Context(), CtxUser, user) + next.ServeHTTP(w, r.WithContext(withUser)) return - } - // take username from sesion and add the user row to the context - user := dbc.GetUserByID(userID) - if user == nil { - // the username in the client's session no longer relates to a - // user in the database (maybe the user was deleted) - session.Options.MaxAge = -1 + + case AuthMethodOIDC: + // OIDC authentication - check session first, then redirect to authorization + session := r.Context().Value(CtxSession).(*sessions.Session) + userID, ok := session.Values["user"].(int) + if !ok { + oidcURL := auth.BuildOIDCAuthURL(auth.GetOIDCAuthEndpoint(), r) + log.Printf("No session found, redirecting to OIDC authorization: %s", oidcURL) + http.Redirect(w, r, oidcURL, http.StatusSeeOther) + return + } + user := dbc.GetUserByID(userID) + if user == nil { + session.Options.MaxAge = -1 + sessLogSave(session, w, r) + oidcURL := auth.BuildOIDCAuthURL(auth.GetOIDCAuthEndpoint(), r) + http.Redirect(w, r, oidcURL, http.StatusSeeOther) + return + } + withUser := context.WithValue(r.Context(), CtxUser, user) + next.ServeHTTP(w, r.WithContext(withUser)) + return + + case AuthMethodOIDCForward: + // OIDC-forward authentication - JWT required in configured header + authHeader := r.Header.Get(auth.GetOIDCHeader()) + if authHeader == "" { + log.Printf("No %s header found for oidc-forward authentication", auth.GetOIDCHeader()) + http.Redirect(w, r, resolvePath("/admin/auth-error"), http.StatusSeeOther) + return + } + + jwtToken := authHeader + if strings.HasPrefix(authHeader, "Bearer ") { + jwtToken = authHeader[7:] + } + + claims, err := auth.ValidateIncomingJWT(jwtToken) + if err != nil { + log.Printf("JWT validation failed: %v", err) + http.Redirect(w, r, resolvePath("/admin/auth-error"), http.StatusSeeOther) + return + } + + log.Printf("JWT validation successful for user: %s", claims.Subject) + + session := r.Context().Value(CtxSession).(*sessions.Session) + user, err := auth.HandleOIDCLogin(dbc, session, claims) + if err != nil { + log.Printf("Error handling OIDC login from JWT: %v", err) + http.Error(w, "Failed to handle OIDC login", 500) + return + } sessLogSave(session, w, r) - http.Redirect(w, r, "/", http.StatusSeeOther) + + withUser := context.WithValue(r.Context(), CtxUser, user) + next.ServeHTTP(w, r.WithContext(withUser)) + return + + default: + http.Error(w, "Unknown authentication method", 500) return } - withUser := context.WithValue(r.Context(), CtxUser, user) - next.ServeHTTP(w, r.WithContext(withUser)) }) } } @@ -295,6 +382,9 @@ type templateData struct { // avatar Avatar []byte + + // custom properties + Props map[string]interface{} } func funcMap() template.FuncMap { @@ -416,3 +506,45 @@ func validateAPIKey(apiKey, secret string) error { } return nil } + +func serveOIDCCallback(dbc *db.DB, w http.ResponseWriter, r *http.Request, resolveProxyPath ProxyPathResolver) { + if auth.GetOIDCAuthEndpoint() == "" { + http.Error(w, "OIDC not configured", http.StatusInternalServerError) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Missing authorization code", http.StatusBadRequest) + return + } + + // Get state parameter (FIXME: should validate this for CSRF protection) + state := r.URL.Query().Get("state") + log.Printf("OIDC callback received: code=%s..., state=%s", code[:min(10, len(code))], state) + + tokenResp, err := auth.ExchangeCodeForTokens(r, code) + if err != nil { + log.Printf("Error exchanging code for tokens: %v", err) + http.Error(w, "Failed to exchange code for tokens", http.StatusInternalServerError) + return + } + + claims, err := auth.ValidateIncomingJWT(tokenResp.IDToken) + if err != nil { + log.Printf("Error validating ID token: %v", err) + http.Error(w, "Invalid ID token", http.StatusUnauthorized) + return + } + + session := r.Context().Value(CtxSession).(*sessions.Session) + _, err = auth.HandleOIDCLogin(dbc, session, claims) + if err != nil { + log.Printf("Error handling OIDC login: %v", err) + http.Error(w, fmt.Sprintf("Failed to handle OIDC login: %v", err), http.StatusInternalServerError) + return + } + sessLogSave(session, w, r) + + http.Redirect(w, r, resolveProxyPath("/admin/home"), http.StatusSeeOther) +} diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index cb0819836..a35610b3f 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -28,7 +28,16 @@ func (c *Controller) ServeNotFound(_ *http.Request) *Response { } func (c *Controller) ServeLogin(_ *http.Request) *Response { - return &Response{template: "login.tmpl"} + data := &templateData{} + data.Props = map[string]interface{}{ + "PasswordAuthEnabled": GetAuthMethod() == "password", + "AuthMethod": GetAuthMethod(), + } + return &Response{template: "login.tmpl", data: data} +} + +func (c *Controller) ServeAuthError(_ *http.Request) *Response { + return &Response{template: "auth_error.tmpl"} } func (c *Controller) ServeHome(r *http.Request) *Response { @@ -203,6 +212,46 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response { return &Response{redirect: "/admin/home"} } +func (c *Controller) ServeRevealPassword(r *http.Request) *Response { + // Get the requested user + selectedUsername := r.URL.Query().Get("user") + if selectedUsername == "" { + return &Response{code: 400, err: "no user specified"} + } + + selectedUser := c.dbc.GetUserByName(selectedUsername) + if selectedUser == nil { + return &Response{code: 400, err: "user not found"} + } + + // Get the current user + currentUser := r.Context().Value(CtxUser).(*db.User) + + // Only allow OIDC users to reveal their own password + if currentUser.ID != selectedUser.ID { + return &Response{code: 403, err: "you can only reveal your own password"} + } + + // Only allow if the user is an OIDC user (has OIDCSubject) + if selectedUser.OIDCSubject == "" { + return &Response{code: 403, err: "password reveal only available for OIDC users"} + } + + // Check if user wants to show password in plain text (no-js fallback) + showPlainText := r.URL.Query().Get("show") == "true" + + data := &templateData{} + data.SelectedUser = selectedUser + // Add a custom field to indicate whether to show plain text + data.Props = map[string]interface{}{ + "ShowPlainText": showPlainText, + } + return &Response{ + template: "reveal_password.tmpl", + data: data, + } +} + func (c *Controller) ServeChangeAvatar(r *http.Request) *Response { user, err := selectedUserIfAdmin(c, r) if err != nil { diff --git a/server/ctrladmin/handlers_raw.go b/server/ctrladmin/handlers_raw.go index 49d9e2a84..06ee5dd8c 100644 --- a/server/ctrladmin/handlers_raw.go +++ b/server/ctrladmin/handlers_raw.go @@ -8,6 +8,15 @@ import ( func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(CtxSession).(*sessions.Session) + + // Check if password authentication is allowed + if GetAuthMethod() != "password" { + sessAddFlashW(session, []string{"password authentication is not available"}) + sessLogSave(session, w, r) + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } + username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" {