diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 5219fcf3c..300f0b7c5 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -9455,8 +9455,6 @@ "type": "integer", "format": "int64", "enum": [ - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -9465,8 +9463,6 @@ 3600000000000 ], "x-enum-varnames": [ - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", diff --git a/internal/commands/cluster_helpers.go b/internal/commands/cluster_helpers.go new file mode 100644 index 000000000..a5b03402f --- /dev/null +++ b/internal/commands/cluster_helpers.go @@ -0,0 +1,55 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/photoprism/photoprism/internal/service/cluster" + "github.com/photoprism/photoprism/pkg/service/http/header" +) + +// obtainClientCredentialsViaRegister calls the portal register endpoint using a join token +// to (re)register the node, rotating the secret when necessary, and returns client id/secret. +func obtainClientCredentialsViaRegister(portalURL, joinToken, nodeName string) (id, secret string, err error) { + u, err := url.Parse(strings.TrimRight(portalURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", "", fmt.Errorf("invalid portal-url: %s", portalURL) + } + endpoint := *u + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/nodes/register" + + reqBody := map[string]any{ + "nodeName": nodeName, + "nodeRole": cluster.RoleInstance, + "rotateSecret": true, + } + b, _ := json.Marshal(reqBody) + req, _ := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + header.SetAuthorization(req, joinToken) + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict { + return "", "", fmt.Errorf("%s", resp.Status) + } + var regResp cluster.RegisterResponse + if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { + return "", "", err + } + id = regResp.Node.ID + if regResp.Secrets != nil { + secret = regResp.Secrets.NodeSecret + } + if id == "" || secret == "" { + return "", "", fmt.Errorf("missing client credentials in response") + } + return id, secret, nil +} diff --git a/internal/commands/cluster_theme_pull.go b/internal/commands/cluster_theme_pull.go index aa77b2dd1..96377f6d2 100644 --- a/internal/commands/cluster_theme_pull.go +++ b/internal/commands/cluster_theme_pull.go @@ -2,9 +2,12 @@ package commands import ( "archive/zip" + "encoding/base64" + "encoding/json" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -25,12 +28,14 @@ var ClusterThemePullCommand = &cli.Command{ Subcommands: []*cli.Command{ { Name: "pull", - Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path", + Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path. If only a join token is provided, this command first registers the node to obtain client credentials, then downloads the theme (no extra command needed).", Flags: []cli.Flag{ &cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""}, &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"}, &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"}, &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"}, + &cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"}, + &cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"}, JsonFlag, }, Action: clusterThemePullAction, @@ -50,15 +55,44 @@ func clusterThemePullAction(ctx *cli.Context) error { if portalURL == "" { return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL") } - token := ctx.String("join-token") - if token == "" { - token = conf.JoinToken() + // Credentials: prefer OAuth client credentials (client-id/secret), fallback to join-token for compatibility. + clientID := ctx.String("client-id") + if clientID == "" { + clientID = conf.NodeID() + } + clientSecret := ctx.String("client-secret") + if clientSecret == "" { + clientSecret = conf.NodeSecret() + } + token := "" + if clientID != "" && clientSecret != "" { + // OAuth client_credentials + t, err := obtainOAuthToken(portalURL, clientID, clientSecret) + if err != nil { + log.Warnf("cluster: oauth token failed, falling back to join token (%s)", clean.Error(err)) + } else { + token = t + } } if token == "" { - token = os.Getenv(config.EnvVar("join-token")) - } - if token == "" { - return fmt.Errorf("join-token not configured; set --join-token or PHOTOPRISM_JOIN_TOKEN") + // Try join-token assisted path. If NodeID/NodeSecret not available, attempt register to obtain them, then OAuth. + jt := ctx.String("join-token") + if jt == "" { + jt = conf.JoinToken() + } + if jt == "" { + jt = os.Getenv(config.EnvVar("join-token")) + } + if jt != "" && (clientID == "" || clientSecret == "") { + if id, sec, err := obtainClientCredentialsViaRegister(portalURL, jt, conf.NodeName()); err == nil { + if t, err := obtainOAuthToken(portalURL, id, sec); err == nil { + token = t + } + } + } + if token == "" { + return fmt.Errorf("authentication required: provide --client-id/--client-secret or a join token to obtain credentials") + } } dest := ctx.Path("dest") @@ -151,6 +185,46 @@ func clusterThemePullAction(ctx *cli.Context) error { }) } +// obtainOAuthToken requests an access token via client_credentials using Basic auth. +func obtainOAuthToken(portalURL, clientID, clientSecret string) (string, error) { + u, err := url.Parse(strings.TrimRight(portalURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid portal-url: %s", portalURL) + } + tokenURL := *u + tokenURL.Path = strings.TrimRight(tokenURL.Path, "/") + "/api/v1/oauth/token" + + form := url.Values{} + form.Set("grant_type", "client_credentials") + req, _ := http.NewRequest(http.MethodPost, tokenURL.String(), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + req.Header.Set("Authorization", "Basic "+basic) + + client := &http.Client{Timeout: cluster.BootstrapRegisterTimeout} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%s", resp.Status) + } + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return "", err + } + if tok.AccessToken == "" { + return "", fmt.Errorf("empty access_token") + } + return tok.AccessToken, nil +} + func dirNonEmpty(dir string) (bool, error) { entries, err := os.ReadDir(dir) if err != nil { diff --git a/internal/commands/cluster_theme_pull_oauth_test.go b/internal/commands/cluster_theme_pull_oauth_test.go new file mode 100644 index 000000000..1d6d4c953 --- /dev/null +++ b/internal/commands/cluster_theme_pull_oauth_test.go @@ -0,0 +1,130 @@ +package commands + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/service/cluster" +) + +// Verifies OAuth path in cluster theme pull using client_id/client_secret. +func TestClusterThemePull_OAuth(t *testing.T) { + // Build an in-memory zip with one file + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + f, _ := zw.Create("ok.txt") + _, _ = f.Write([]byte("ok\n")) + _ = zw.Close() + + // Fake portal server + var gotBasic string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/oauth/token": + // Expect Basic auth for nodeid:secret + gotBasic = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer", "scope": "cluster vision"}) + case "/api/v1/cluster/theme": + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(zipBuf.Bytes()) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + // Prepare destination + dest := t.TempDir() + // Run CLI with OAuth creds + out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{ + "pull", "--dest", dest, "-f", + "--portal-url=" + ts.URL, + "--client-id=nodeid", + "--client-secret=secret", + }) + _ = out + assert.NoError(t, err) + // Verify file extracted + assert.FileExists(t, filepath.Join(dest, "ok.txt")) + // Verify Basic header format + expect := "Basic " + base64.StdEncoding.EncodeToString([]byte("nodeid:secret")) + assert.Equal(t, expect, gotBasic) +} + +// Verifies that when only a join token is provided, the command obtains +// client credentials via the register endpoint, then uses OAuth to pull the theme. +func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) { + // Zip fixture + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + _, _ = zw.Create("ok2.txt") + _ = zw.Close() + + // Fake portal server responds with register then token then theme + var sawRotateSecret bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/cluster/nodes/register": + // Must have Bearer join token + if r.Header.Get("Authorization") != "Bearer jt" { + w.WriteHeader(http.StatusUnauthorized) + return + } + // Read body to check rotateSecret flag + var req struct { + RotateSecret bool `json:"rotateSecret"` + NodeName string `json:"nodeName"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + sawRotateSecret = req.RotateSecret + w.Header().Set("Content-Type", "application/json") + // Return NodeID and a fresh secret + _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{ + Node: cluster.Node{ID: "cid123", Name: "pp-node-01"}, + Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"}, + }) + case "/api/v1/oauth/token": + // Expect Basic for the returned creds + if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("cid123:s3cr3t")) { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok2", "token_type": "Bearer"}) + case "/api/v1/cluster/theme": + if r.Header.Get("Authorization") != "Bearer tok2" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(zipBuf.Bytes()) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + dest := t.TempDir() + out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{ + "pull", "--dest", dest, "-f", + "--portal-url=" + ts.URL, + "--join-token=jt", + }) + _ = out + assert.NoError(t, err) + assert.True(t, sawRotateSecret) +} diff --git a/internal/service/cluster/instance/bootstrap.go b/internal/service/cluster/instance/bootstrap.go index 1ffec8de2..e077b9ee3 100644 --- a/internal/service/cluster/instance/bootstrap.go +++ b/internal/service/cluster/instance/bootstrap.go @@ -1,8 +1,10 @@ package instance import ( + "encoding/base64" "encoding/json" "errors" + "fmt" "io" "net" "net/http" @@ -199,6 +201,11 @@ func isTemporary(err error) bool { func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRotateDatabase bool) error { updates := map[string]interface{}{} + // Always persist NodeID (client UID) from response for future OAuth token requests. + if r.Node.ID != "" { + updates["NodeID"] = r.Node.ID + } + // Persist node secret only if missing locally and provided by server. if r.Secrets != nil && r.Secrets.NodeSecret != "" && c.NodeSecret() == "" { updates["NodeSecret"] = r.Secrets.NodeSecret @@ -297,8 +304,23 @@ func installThemeIfMissing(c *config.Config, portal *url.URL, token string) erro endpoint := *portal endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/cluster/theme" + // Prefer OAuth client-credentials using NodeID/NodeSecret if available; fallback to join token. + bearer := "" + if id, secret := strings.TrimSpace(c.NodeID()), strings.TrimSpace(c.NodeSecret()); id != "" && secret != "" { + if t, err := oauthAccessToken(c, portal, id, secret); err != nil { + log.Debugf("cluster: oauth token request failed (%s)", clean.Error(err)) + } else { + bearer = t + } + } + // If we do not have a bearer token, skip theme install for this run (no insecure fallback). + if bearer == "" { + log.Debugf("cluster: theme install skipped (missing OAuth credentials)") + return nil + } + req, _ := http.NewRequest(http.MethodGet, endpoint.String(), nil) - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+bearer) req.Header.Set("Accept", "application/zip") resp, err := newHTTPClient(cluster.BootstrapRegisterTimeout).Do(req) @@ -339,3 +361,44 @@ func installThemeIfMissing(c *config.Config, portal *url.URL, token string) erro return errors.New(resp.Status) } } + +// oauthAccessToken requests an OAuth access token via client_credentials using Basic auth. +func oauthAccessToken(c *config.Config, portal *url.URL, clientID, clientSecret string) (string, error) { + if portal == nil { + return "", fmt.Errorf("invalid portal url") + } + tokenURL := *portal + tokenURL.Path = strings.TrimRight(tokenURL.Path, "/") + "/api/v1/oauth/token" + + form := url.Values{} + form.Set("grant_type", "client_credentials") + + req, _ := http.NewRequest(http.MethodPost, tokenURL.String(), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + // Basic auth for client credentials + basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + req.Header.Set("Authorization", "Basic "+basic) + + resp, err := newHTTPClient(cluster.BootstrapRegisterTimeout).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%s", resp.Status) + } + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + } + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&tok); err != nil { + return "", err + } + if tok.AccessToken == "" { + return "", fmt.Errorf("empty access_token") + } + return tok.AccessToken, nil +} diff --git a/internal/service/cluster/instance/bootstrap_test.go b/internal/service/cluster/instance/bootstrap_test.go index e8471ba74..b5c17453f 100644 --- a/internal/service/cluster/instance/bootstrap_test.go +++ b/internal/service/cluster/instance/bootstrap_test.go @@ -78,12 +78,16 @@ func TestThemeInstall_Missing(t *testing.T) { _, _ = f.Write([]byte("body{}\n")) _ = zw.Close() - // Fake Portal server. + // Fake Portal server (register -> oauth token -> theme) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/cluster/nodes/register": w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{Node: cluster.Node{Name: "pp-node-01"}}) + // Return NodeID + NodeSecret so bootstrap can request OAuth token + _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{Node: cluster.Node{ID: "cid123", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{NodeSecret: "s3cr3t"}}) + case "/api/v1/oauth/token": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"}) case "/api/v1/cluster/theme": w.Header().Set("Content-Type", "application/zip") w.WriteHeader(http.StatusOK) diff --git a/internal/service/cluster/registry/client.go b/internal/service/cluster/registry/client.go index 8aa10c3aa..5be711786 100644 --- a/internal/service/cluster/registry/client.go +++ b/internal/service/cluster/registry/client.go @@ -11,13 +11,13 @@ import ( ) // ClientRegistry implements Registry using auth_clients + passwords. -type ClientRegistry struct{} +type ClientRegistry struct{ conf *config.Config } func NewClientRegistry() *ClientRegistry { return &ClientRegistry{} } // NewClientRegistryWithConfig returns a client-backed registry; the config is accepted for parity with file-backed init. -func NewClientRegistryWithConfig(_ *config.Config) (*ClientRegistry, error) { - return &ClientRegistry{}, nil +func NewClientRegistryWithConfig(c *config.Config) (*ClientRegistry, error) { + return &ClientRegistry{conf: c}, nil } // toNode maps an auth client to the registry.Node DTO used by response builders. @@ -84,6 +84,14 @@ func (r *ClientRegistry) Put(n *Node) error { if n.Role != "" { m.SetRole(n.Role) } + // Ensure a default scope for node clients (instance/service) if none is set. + // Always include "vision"; this only permits access to Vision endpoints WHEN the Portal enables them. + if m.Scope() == "" { + role := m.AclRole().String() + if role == "instance" || role == "service" { + m.SetScope("cluster vision") + } + } if n.AdvertiseUrl != "" { m.ClientURL = n.AdvertiseUrl }