Auth: Support asymmetric JSON Web Tokens (JWT) and Key Sets (JWKS) #5230

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-25 17:52:44 +02:00
parent 4828c0423d
commit bae8ceb3a7
77 changed files with 2016 additions and 206 deletions

View File

@@ -259,6 +259,7 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
### API & Config Changes ### API & Config Changes
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags. - Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
- When touching configuration in Go code, use the public accessors on `*config.Config` (e.g. `Config.JWKSUrl()`, `Config.SetJWKSUrl()`, `Config.ClusterUUID()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures only.
- Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted). - Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted).
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early. - Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.
- Handler conventions: reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s, lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers, compare secrets with constant time checks, set `Cache-Control: no-store` on sensitive responses, and register routes in `internal/server/routes.go`. For new list endpoints default `count=100` (max 1000) and `offset≥0`, document parameters explicitly, and set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed. - Handler conventions: reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s, lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers, compare secrets with constant time checks, set `Cache-Control: no-store` on sensitive responses, and register routes in `internal/server/routes.go`. For new list endpoints default `count=100` (max 1000) and `offset≥0`, document parameters explicitly, and set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed.

1
go.mod
View File

@@ -80,6 +80,7 @@ require (
github.com/davidbyttow/govips/v2 v2.16.0 github.com/davidbyttow/govips/v2 v2.16.0
github.com/go-co-op/gocron/v2 v2.16.5 github.com/go-co-op/gocron/v2 v2.16.5
github.com/go-sql-driver/mysql v1.9.0 github.com/go-sql-driver/mysql v1.9.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/prometheus/client_model v0.6.2 github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1

2
go.sum
View File

@@ -202,6 +202,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= 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-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=

View File

@@ -29,14 +29,19 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
clientIp := ClientIP(c) clientIp := ClientIP(c)
authToken := AuthToken(c) authToken := AuthToken(c)
// Disable response caching.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Find active session to perform authorization check or deny if no session was found. // Find active session to perform authorization check or deny if no session was found.
if s = Session(clientIp, authToken); s == nil { if s = Session(clientIp, authToken); s == nil {
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
return s
}
event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource)) event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
return entity.SessionStatusUnauthorized() return entity.SessionStatusUnauthorized()
} }
// Disable caching of responses and the client IP. // Set client IP.
c.Header(header.CacheControl, header.CacheControlNoStore)
s.SetClientIP(clientIp) s.SetClientIP(clientIp)
// If the request is from a client application, check its authorization based // If the request is from a client application, check its authorization based

View File

@@ -0,0 +1,124 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
)
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
// node receives a request without an existing session. It verifies the token
// against the node's cached JWKS, ensures the issuer/audience/scope match the
// expected portal values, and, if valid, returns a client session mirroring the
// JWT claims. It returns nil on any validation failure so the caller can fall
// back to existing auth flows. Currently cluster and vision resources are
// eligible for JWT-based authorization; vision access requires the `vision`
// scope whereas cluster access requires the `cluster` scope.
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
if c == nil || authToken == "" {
return nil
}
_ = perms
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
return nil
}
// Basic sanity check for JWT structure.
if strings.Count(authToken, ".") != 2 {
return nil
}
conf := get.Config()
if conf == nil || conf.IsPortal() {
return nil
}
if conf.JWKSUrl() == "" {
return nil
}
verifier := clusterjwt.NewVerifier(conf)
requiredScopes := []string{"cluster"}
if resource == acl.ResourceVision {
requiredScopes = []string{"vision"}
}
expected := clusterjwt.ExpectedClaims{
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
Scope: requiredScopes,
JWKSURL: conf.JWKSUrl(),
}
issuers := jwtIssuerCandidates(conf)
if len(issuers) == 0 {
return nil
}
var (
claims *clusterjwt.Claims
err error
)
ctx := c.Request.Context()
for _, issuer := range issuers {
expected.Issuer = issuer
claims, err = verifier.VerifyToken(ctx, authToken, expected)
if err == nil {
break
}
}
if err != nil {
return nil
} else if claims == nil {
return nil
}
sess := &entity.Session{
Status: http.StatusOK,
ClientUID: claims.Subject,
AuthScope: clean.Scope(claims.Scope),
AuthIssuer: claims.Issuer,
AuthID: claims.ID,
GrantType: authn.GrantJwtBearer.String(),
AuthProvider: authn.ProviderClient.String(),
}
sess.SetMethod(authn.MethodJWT)
sess.SetClientName(claims.Subject)
sess.SetClientIP(clientIP)
return sess
}
// jwtIssuerCandidates returns the possible issuer values the node should accept
// for Portal JWTs. It prefers the explicit portal cluster identifier and then
// falls back to configured URLs so legacy installations migrate seamlessly.
func jwtIssuerCandidates(conf *config.Config) []string {
var out []string
if uuid := conf.ClusterUUID(); uuid != "" {
out = append(out, fmt.Sprintf("portal:%s", uuid))
}
if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
out = append(out, strings.TrimRight(portal, "/"))
}
if site := strings.TrimSpace(conf.SiteUrl()); site != "" {
out = append(out, strings.TrimRight(site, "/"))
}
return out
}

View File

@@ -0,0 +1,148 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
func TestAuthAnyJWT(t *testing.T) {
t.Run("ClusterScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-success")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Equal(t, spec.Subject, session.ClientUID)
assert.Contains(t, session.AuthScope, "cluster")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("VisionScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/vision/status", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.18.0.5:8080"
c.Request = req
session := authAnyJWT(c, "198.18.0.5", token, acl.ResourceVision, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Contains(t, session.AuthScope, "vision")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("RejectsMalformedOrUnknown", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-invalid")
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer invalid-token-without-dots")
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "invalid-token-without-dots", acl.ResourceCluster, nil))
// Ensure we also bail out when JWKS URL is not configured.
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "", acl.ResourceCluster, nil))
})
t.Run("NoIssuerMatch", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-no-issuer")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
// Remove all issuer candidates.
origPortal := fx.nodeConf.Options().PortalUrl
origSite := fx.nodeConf.Options().SiteUrl
origClusterUUID := fx.nodeConf.Options().ClusterUUID
fx.nodeConf.Options().PortalUrl = ""
fx.nodeConf.Options().SiteUrl = ""
fx.nodeConf.Options().ClusterUUID = ""
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().PortalUrl = origPortal
fx.nodeConf.Options().SiteUrl = origSite
fx.nodeConf.Options().ClusterUUID = origClusterUUID
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "203.0.113.5:2222"
c.Request = req
assert.Nil(t, authAnyJWT(c, "203.0.113.5", token, acl.ResourceCluster, nil))
})
t.Run("UnsupportedResource", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-unsupported")
token := fx.issue(t, fx.defaultClaimsSpec())
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.51.100.7:9999"
c.Request = req
assert.Nil(t, authAnyJWT(c, "198.51.100.7", token, acl.ResourcePhotos, nil))
})
}
func TestJwtIssuerCandidates(t *testing.T) {
t.Run("IncludesAllSources", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
conf.Options().PortalUrl = "https://portal.example.test/"
conf.Options().SiteUrl = "https://site.example.test/base/"
orig := get.Config()
get.SetConfig(conf)
t.Cleanup(func() { get.SetConfig(orig) })
cands := jwtIssuerCandidates(conf)
assert.Equal(t, []string{
"portal:11111111-1111-4111-8111-111111111111",
"https://portal.example.test",
"https://site.example.test/base",
}, cands)
})
t.Run("DefaultsToLocalhost", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = ""
conf.Options().PortalUrl = ""
conf.Options().SiteUrl = ""
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
})
}

View File

@@ -1,15 +1,23 @@
package api package api
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/session" "github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header" "github.com/photoprism/photoprism/pkg/service/http/header"
) )
@@ -137,3 +145,167 @@ func TestAuthToken(t *testing.T) {
assert.Equal(t, "", bearerToken) assert.Equal(t, "", bearerToken)
}) })
} }
func TestAuthAnyPortalJWT(t *testing.T) {
fx := newPortalJWTFixture(t, "ok")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.True(t, s.IsClient())
assert.Equal(t, http.StatusOK, s.HttpStatus())
assert.Contains(t, s.AuthScope, "cluster")
assert.Equal(t, fmt.Sprintf("portal:%s", fx.clusterUUID), s.AuthIssuer)
assert.Equal(t, "portal:client-test", s.ClientUID)
assert.False(t, s.Abort(c))
// Audience mismatch should reject the token once the node UUID changes.
req2, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.RemoteAddr = "10.0.0.5:1234"
c.Request = req2
fx.nodeConf.Options().NodeUUID = rnd.UUID()
get.SetConfig(fx.nodeConf)
s = AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_MissingScope(t *testing.T) {
fx := newPortalJWTFixture(t, "missing-scope")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_InvalidIssuer(t *testing.T) {
fx := newPortalJWTFixture(t, "invalid-issuer")
spec := fx.defaultClaimsSpec()
spec.Issuer = "https://portal.invalid.test"
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_NoJWKSConfigured(t *testing.T) {
fx := newPortalJWTFixture(t, "no-jwks")
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
type portalJWTFixture struct {
nodeConf *config.Config
issuer *clusterjwt.Issuer
clusterUUID string
nodeUUID string
}
func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
t.Helper()
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
origConf := get.Config()
t.Cleanup(func() { get.SetConfig(origConf) })
nodeConf := config.NewTestConfig("auth-any-portal-jwt-" + suffix)
nodeConf.Options().NodeRole = cluster.RoleInstance
nodeConf.Options().Public = false
clusterUUID := rnd.UUID()
nodeConf.Options().ClusterUUID = clusterUUID
nodeUUID := nodeConf.NodeUUID()
nodeConf.Options().PortalUrl = "https://portal.example.test"
portalConf := config.NewTestConfig("auth-any-portal-jwt-issuer-" + suffix)
portalConf.Options().NodeRole = cluster.RolePortal
portalConf.Options().ClusterUUID = clusterUUID
mgr, err := clusterjwt.NewManager(portalConf)
require.NoError(t, err)
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(jwksBytes)
}))
t.Cleanup(srv.Close)
nodeConf.SetJWKSUrl(srv.URL + "/.well-known/jwks.json")
get.SetConfig(nodeConf)
return portalJWTFixture{
nodeConf: nodeConf,
issuer: clusterjwt.NewIssuer(mgr),
clusterUUID: clusterUUID,
nodeUUID: nodeUUID,
}
}
func (fx portalJWTFixture) defaultClaimsSpec() clusterjwt.ClaimsSpec {
return clusterjwt.ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
Scope: []string{"cluster", "vision"},
}
}
func (fx portalJWTFixture) issue(t *testing.T, spec clusterjwt.ClaimsSpec) string {
t.Helper()
token, err := fx.issuer.Issue(spec)
require.NoError(t, err)
return token
}

View File

@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
// Run unit tests. // Run unit tests.
code := m.Run() code := m.Run()
// Purge local SQLite test artifacts created during this package's tests. // Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)

View File

@@ -24,10 +24,10 @@ func TestClusterListNodes_Redaction(t *testing.T) {
// Seed one node with internal URL and DB metadata. // Seed one node with internal URL and DB metadata.
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List(). // Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"} n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}}
n.Database.Name = "pp_db" n.Database = &cluster.NodeDatabase{Name: "pp_db", User: "pp_user"}
n.Database.User = "pp_user"
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Admin session shows internal fields // Admin session shows internal fields
@@ -55,10 +55,10 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
// Seed node with internal URL and DB meta. // Seed node with internal URL and DB meta.
n := &reg.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"} n := &reg.Node{Node: cluster.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}}
n.Database.Name = "pp_db2" n.Database = &cluster.NodeDatabase{Name: "pp_db2", User: "pp_user2"}
n.Database.User = "pp_user2"
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Create client session with cluster scope and no user (redacted view expected). // Create client session with cluster scope and no user (redacted view expected).

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
@@ -68,18 +69,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
} }
// Parse request. // Parse request.
var req struct { var req cluster.RegisterRequest
NodeName string `json:"nodeName"`
NodeUUID string `json:"nodeUUID"`
NodeRole string `json:"nodeRole"`
Labels map[string]string `json:"labels"`
AdvertiseUrl string `json:"advertiseUrl"`
SiteUrl string `json:"siteUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RotateDatabase bool `json:"rotateDatabase"`
RotateSecret bool `json:"rotateSecret"`
}
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "form invalid", "%s"}, clean.Error(err)) event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "form invalid", "%s"}, clean.Error(err))
@@ -227,13 +217,22 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate db", event.Succeeded, "node %s"}, clean.LogQuote(name)) event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate db", event.Succeeded, "node %s"}, clean.LogQuote(name))
} }
jwksURL := buildJWKSURL(conf)
// Build response with struct types. // Build response with struct types.
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
dbInfo := cluster.NodeDatabase{}
if n.Database != nil {
dbInfo = *n.Database
}
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(), UUID: conf.ClusterUUID(),
Node: reg.BuildClusterNode(*n, opts), Node: reg.BuildClusterNode(*n, opts),
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver}, Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
Secrets: respSecret, Secrets: respSecret,
JWKSUrl: jwksURL,
AlreadyRegistered: true, AlreadyRegistered: true,
AlreadyProvisioned: true, AlreadyProvisioned: true,
} }
@@ -252,14 +251,18 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// New node (client UID will be generated in registry.Put). // New node (client UID will be generated in registry.Put).
n := &reg.Node{ n := &reg.Node{
Name: name, Node: cluster.Node{
Role: clean.TypeLowerDash(req.NodeRole), Name: name,
UUID: requestedUUID, Role: clean.TypeLowerDash(req.NodeRole),
Labels: req.Labels, UUID: requestedUUID,
Labels: req.Labels,
},
} }
if n.UUID == "" { if n.UUID == "" {
n.UUID = rnd.UUIDv7() n.UUID = rnd.UUIDv7()
} }
// Derive a sensible default advertise URL when not provided by the client. // Derive a sensible default advertise URL when not provided by the client.
if req.AdvertiseUrl != "" { if req.AdvertiseUrl != "" {
n.AdvertiseUrl = req.AdvertiseUrl n.AdvertiseUrl = req.AdvertiseUrl
@@ -281,6 +284,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return return
} }
if n.Database == nil {
n.Database = &cluster.NodeDatabase{}
}
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver n.Database.Driver = provisioner.DatabaseDriver
@@ -294,6 +302,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)), Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt}, Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt}, Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},
JWKSUrl: buildJWKSURL(conf),
AlreadyRegistered: false, AlreadyRegistered: false,
AlreadyProvisioned: false, AlreadyProvisioned: false,
} }
@@ -348,5 +357,23 @@ func validateAdvertiseURL(u string) bool {
return false return false
} }
func buildJWKSURL(conf *config.Config) string {
if conf == nil {
return "/.well-known/jwks.json"
}
path := conf.BaseUri("/.well-known/jwks.json")
if path == "" {
path = "/.well-known/jwks.json"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
site := strings.TrimRight(conf.SiteUrl(), "/")
if site == "" {
return path
}
return site + path
}
// validateSiteURL applies the same rules as validateAdvertiseURL. // validateSiteURL applies the same rules as validateAdvertiseURL.
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) } func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }

View File

@@ -31,7 +31,7 @@ func TestClusterNodesRegister(t *testing.T) {
// Pre-create a node via registry and rotate to get a plaintext secret for tests // Pre-create a node via registry and rotate to get a plaintext secret for tests
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"} n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
nr, err := regy.RotateSecret(n.UUID) nr, err := regy.RotateSecret(n.UUID)
assert.NoError(t, err) assert.NoError(t, err)
@@ -84,8 +84,9 @@ func TestClusterNodesRegister(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
// Pre-create node with a UUID // Pre-create node with a UUID
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"} n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Attempt to change UUID via name without client credentials → 409 // Attempt to change UUID via name without client credentials → 409
@@ -172,7 +173,7 @@ func TestClusterNodesRegister(t *testing.T) {
// used by OAuth tests running in the same package. // used by OAuth tests running in the same package.
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance"} n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n") r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
@@ -195,7 +196,7 @@ func TestClusterNodesRegister(t *testing.T) {
// Pre-create node in registry so handler goes through existing-node path. // Pre-create node in registry so handler goes through existing-node path.
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-02", Role: "instance"} n := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "instance"}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
// Provisioner is independent; endpoint should respond 200 and persist metadata. // Provisioner is independent; endpoint should respond 200 and persist metadata.

View File

@@ -27,10 +27,13 @@ func TestClusterEndpoints(t *testing.T) {
// Seed nodes in the registry // Seed nodes in the registry
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
n2 := &reg.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
n2 := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n2)) assert.NoError(t, regy.Put(n2))
// Resolve actual IDs (client-backed registry generates IDs) // Resolve actual IDs (client-backed registry generates IDs)
n, err = regy.FindByName("pp-node-01") n, err = regy.FindByName("pp-node-01")
assert.NoError(t, err) assert.NoError(t, err)
@@ -87,8 +90,10 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Seed a node and resolve its actual ID. // Seed a node and resolve its actual ID.
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-99") n, err = regy.FindByName("pp-node-99")
assert.NoError(t, err) assert.NoError(t, err)
@@ -114,9 +119,11 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Excessively long ID (>64 chars) is rejected. // Excessively long ID (>64 chars) is rejected.
longID := make([]byte, 65) longID := make([]byte, 65)
for i := range longID { for i := range longID {
longID[i] = 'a' longID[i] = 'a'
} }
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+string(longID)) r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+string(longID))
assert.Equal(t, http.StatusNotFound, r.Code) assert.Equal(t, http.StatusNotFound, r.Code)
} }

View File

@@ -21,8 +21,9 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf) regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err) assert.NoError(t, err)
// Seed node // Seed node
n := &reg.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()} n := &reg.Node{Node: cluster.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n)) assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-siteurl") n, err = regy.FindByName("pp-node-siteurl")
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -422,6 +422,9 @@
"database": { "database": {
"$ref": "#/definitions/cluster.RegisterDatabase" "$ref": "#/definitions/cluster.RegisterDatabase"
}, },
"jwksUrl": {
"type": "string"
},
"node": { "node": {
"$ref": "#/definitions/cluster.Node" "$ref": "#/definitions/cluster.Node"
}, },

106
internal/auth/jwt/issuer.go Normal file
View File

@@ -0,0 +1,106 @@
package jwt
import (
"errors"
"strings"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/pkg/rnd"
)
var (
// DefaultTokenTTL is the default lifetime for issued tokens.
DefaultTokenTTL = 300 * time.Second
// MaxTokenTTL clamps configurable lifetimes to a safe upper bound.
MaxTokenTTL = 900 * time.Second
)
// TokenTTL controls the default lifetime used when a ClaimsSpec does not override TTL.
var TokenTTL = DefaultTokenTTL
// ClaimsSpec describes the claims to embed in a signed token.
type ClaimsSpec struct {
Issuer string
Subject string
Audience string
Scope []string
TTL time.Duration
}
func (s ClaimsSpec) validate() error {
if strings.TrimSpace(s.Issuer) == "" {
return errors.New("jwt: issuer required")
}
if strings.TrimSpace(s.Subject) == "" {
return errors.New("jwt: subject required")
}
if strings.TrimSpace(s.Audience) == "" {
return errors.New("jwt: audience required")
}
if len(s.Scope) == 0 {
return errors.New("jwt: scope required")
}
return nil
}
// Issuer signs JWTs on behalf of the Portal using the manager's active key.
type Issuer struct {
manager *Manager
now func() time.Time
}
// NewIssuer returns an Issuer bound to the provided Manager.
func NewIssuer(m *Manager) *Issuer {
return &Issuer{manager: m, now: time.Now}
}
// Issue signs a JWT using the manager's active key according to spec.
func (i *Issuer) Issue(spec ClaimsSpec) (string, error) {
if i == nil || i.manager == nil {
return "", errors.New("jwt: issuer not initialized")
}
if err := spec.validate(); err != nil {
return "", err
}
ttl := spec.TTL
if ttl <= 0 {
ttl = TokenTTL
}
if ttl > MaxTokenTTL {
ttl = MaxTokenTTL
}
key, err := i.manager.EnsureActiveKey()
if err != nil {
return "", err
}
issuedAt := i.now().UTC()
expiresAt := issuedAt.Add(ttl)
claims := &Claims{
Scope: strings.Join(spec.Scope, " "),
RegisteredClaims: gojwt.RegisteredClaims{
Issuer: spec.Issuer,
Subject: spec.Subject,
Audience: gojwt.ClaimStrings{spec.Audience},
IssuedAt: gojwt.NewNumericDate(issuedAt),
NotBefore: gojwt.NewNumericDate(issuedAt),
ExpiresAt: gojwt.NewNumericDate(expiresAt),
ID: rnd.GenerateUID(rnd.PrefixMixed),
},
}
token := gojwt.NewWithClaims(gojwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.Kid
token.Header["typ"] = "JWT"
signed, err := token.SignedString(key.PrivateKey)
if err != nil {
return "", err
}
return signed, nil
}

27
internal/auth/jwt/jwt.go Normal file
View File

@@ -0,0 +1,27 @@
/*
Package jwt provides helpers for managing Ed25519 signing keys and issuing or
verifying short-lived JWTs used for secure communication between the Portal and
cluster nodes.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package jwt

View File

@@ -0,0 +1,17 @@
package jwt
import (
"os"
"testing"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -0,0 +1,285 @@
package jwt
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
privateKeyPrefix = "ed25519-"
privateKeyExt = ".jwk"
publicKeyExt = ".pub.jwk"
)
type keyRecord struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
D string `json:"d,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
NotAfter int64 `json:"notAfter,omitempty"`
}
// Manager handles Ed25519 key lifecycle for JWT issuance and JWKS exposure.
type Manager struct {
conf *config.Config
mu sync.RWMutex
keys []*Key
now func() time.Time
}
// ErrNoActiveKey indicates that the manager has no active key pair available.
var ErrNoActiveKey = errors.New("jwt: no active signing key")
// NewManager creates a Manager bound to the provided config.
func NewManager(conf *config.Config) (*Manager, error) {
if conf == nil {
return nil, errors.New("jwt: config is nil")
}
m := &Manager{
conf: conf,
now: time.Now,
}
if err := m.loadKeys(); err != nil {
return nil, err
}
return m, nil
}
// keyDir returns the directory in which key material is stored.
func (m *Manager) keyDir() string {
return filepath.Join(m.conf.PortalConfigPath(), "keys")
}
// EnsureActiveKey returns the current active key, generating one if necessary.
func (m *Manager) EnsureActiveKey() (*Key, error) {
if k, err := m.ActiveKey(); err == nil {
return k, nil
}
return m.generateKey()
}
// ActiveKey returns the most recent, non-expired signing key.
func (m *Manager) ActiveKey() (*Key, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
for i := len(m.keys) - 1; i >= 0; i-- {
k := m.keys[i]
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
return k.clone(), nil
}
return nil, ErrNoActiveKey
}
// JWKS returns the public JWKS representation of all non-expired keys.
func (m *Manager) JWKS() *JWKS {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
keys := make([]PublicJWK, 0, len(m.keys))
for _, k := range m.keys {
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
keys = append(keys, PublicJWK{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
})
}
return &JWKS{Keys: keys}
}
// AllKeys returns a slice copy containing all loaded keys (for testing/inspection).
func (m *Manager) AllKeys() []*Key {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*Key, len(m.keys))
for i, k := range m.keys {
out[i] = k.clone()
}
return out
}
func (m *Manager) loadKeys() error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
keys := make([]*Key, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
continue
}
if !strings.HasPrefix(name, privateKeyPrefix) || !strings.HasSuffix(name, privateKeyExt) {
continue
}
if strings.HasSuffix(name, publicKeyExt) {
// Skip public-only artifacts when reloading.
continue
}
keyPath := filepath.Join(dir, name)
b, err := os.ReadFile(keyPath)
if err != nil {
return err
}
var rec keyRecord
if err := json.Unmarshal(b, &rec); err != nil {
return err
}
if rec.Kty != keyTypeOKP || rec.Crv != curveEd25519 || rec.Kid == "" {
continue
}
privBytes, err := base64.RawURLEncoding.DecodeString(rec.D)
if err != nil {
return err
}
if len(privBytes) != ed25519.SeedSize {
return fmt.Errorf("jwt: invalid private key length %d", len(privBytes))
}
priv := ed25519.NewKeyFromSeed(privBytes)
pub := make([]byte, ed25519.PublicKeySize)
copy(pub, priv[ed25519.SeedSize:])
k := &Key{
Kid: rec.Kid,
CreatedAt: rec.CreatedAt,
NotAfter: rec.NotAfter,
PrivateKey: priv,
PublicKey: ed25519.PublicKey(pub),
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return keys[i].CreatedAt < keys[j].CreatedAt
})
m.mu.Lock()
m.keys = keys
m.mu.Unlock()
return nil
}
func (m *Manager) generateKey() (*Key, error) {
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
priv := ed25519.NewKeyFromSeed(seed)
pub := priv[ed25519.SeedSize:]
now := m.now().UTC()
fingerprint := sha256.Sum256(pub)
kid := fmt.Sprintf("%s-%s", now.Format("20060102T1504Z"), hex.EncodeToString(fingerprint[:4]))
k := &Key{
Kid: kid,
CreatedAt: now.Unix(),
NotAfter: 0,
PrivateKey: priv,
PublicKey: append(ed25519.PublicKey(nil), pub...),
}
if err := m.persistKey(k); err != nil {
return nil, err
}
m.mu.Lock()
m.keys = append(m.keys, k)
sort.Slice(m.keys, func(i, j int) bool {
return m.keys[i].CreatedAt < m.keys[j].CreatedAt
})
m.mu.Unlock()
return k.clone(), nil
}
func (m *Manager) persistKey(k *Key) error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
privRec := keyRecord{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
D: base64.RawURLEncoding.EncodeToString(k.PrivateKey.Seed()),
CreatedAt: k.CreatedAt,
NotAfter: k.NotAfter,
}
privPath := filepath.Join(dir, privateKeyPrefix+k.Kid+privateKeyExt)
pubPath := filepath.Join(dir, privateKeyPrefix+k.Kid+publicKeyExt)
privJSON, err := json.Marshal(privRec)
if err != nil {
return err
}
if err := os.WriteFile(privPath, privJSON, fs.ModeSecretFile); err != nil {
return err
}
// Public record omits private component.
pubRec := privRec
pubRec.D = ""
pubJSON, err := json.Marshal(pubRec)
if err != nil {
return err
}
if err := os.WriteFile(pubPath, pubJSON, fs.ModeFile); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,82 @@
package jwt
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestManagerEnsureActiveKey(t *testing.T) {
c := cfg.NewTestConfig("jwt-manager-active")
m, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m)
fixed := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return fixed }
key, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotNil(t, key)
require.True(t, strings.HasPrefix(key.Kid, "20250924T1030Z-"))
// Key files should be persisted.
privPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+privateKeyExt)
pubPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+publicKeyExt)
require.True(t, fs.FileExists(privPath))
require.True(t, fs.FileExists(pubPath))
// Second call should reuse same key.
next, err := m.EnsureActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, next.Kid)
// JWKS should expose the key.
jwks := m.JWKS()
require.Len(t, jwks.Keys, 1)
require.Equal(t, key.Kid, jwks.Keys[0].Kid)
// Reload manager from disk.
m2, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m2)
reloaded, err := m2.ActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, reloaded.Kid)
}
func TestManagerGenerateSecondKey(t *testing.T) {
c := cfg.NewTestConfig("jwt-manager-rotate")
m, err := NewManager(c)
require.NoError(t, err)
first := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return first }
k1, err := m.EnsureActiveKey()
require.NoError(t, err)
second := first.Add(24 * time.Hour)
m.now = func() time.Time { return second }
// Force generation by clearing in-memory keys to simulate expiration.
m.mu.Lock()
m.keys[len(m.keys)-1].NotAfter = first.Unix()
m.mu.Unlock()
k2, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotEqual(t, k1.Kid, k2.Kid)
// JWKS should include both keys (old not expired due to manual NotAfter=CreatedAt).
jwks := m.JWKS()
require.NotEmpty(t, jwks.Keys)
// Clean up generated files.
require.NoError(t, os.RemoveAll(filepath.Join(c.PortalConfigPath(), "keys")))
}

View File

@@ -0,0 +1,56 @@
package jwt
import (
"crypto/ed25519"
gojwt "github.com/golang-jwt/jwt/v5"
)
const (
keyTypeOKP = "OKP"
curveEd25519 = "Ed25519"
)
// PublicJWK represents the public portion of an Ed25519 key in JWK form.
type PublicJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
}
// JWKS represents a JSON Web Key Set.
type JWKS struct {
Keys []PublicJWK `json:"keys"`
}
// Claims represents cluster JWT claims.
type Claims struct {
Scope string `json:"scope"`
gojwt.RegisteredClaims
}
// Key encapsulates an Ed25519 keypair with metadata used for JWKS rotation.
type Key struct {
Kid string
CreatedAt int64
NotAfter int64
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
// clone returns a shallow copy of the key to avoid exposing internal slices.
func (k *Key) clone() *Key {
if k == nil {
return nil
}
c := *k
if k.PrivateKey != nil {
c.PrivateKey = append(ed25519.PrivateKey(nil), k.PrivateKey...)
}
if k.PublicKey != nil {
c.PublicKey = append(ed25519.PublicKey(nil), k.PublicKey...)
}
return &c
}

View File

@@ -0,0 +1,271 @@
package jwt
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
var (
errKeyNotFound = errors.New("jwt: key not found")
)
type cacheEntry struct {
URL string `json:"url"`
ETag string `json:"etag,omitempty"`
Keys []PublicJWK `json:"keys"`
FetchedAt int64 `json:"fetchedAt"`
}
// Verifier validates Portal-issued JWTs on Nodes using JWKS with caching.
type Verifier struct {
conf *config.Config
mu sync.Mutex
cache cacheEntry
cachePath string
httpClient *http.Client
now func() time.Time
}
// ExpectedClaims describes the constraints that must hold for a token.
type ExpectedClaims struct {
Issuer string
Audience string
Scope []string
JWKSURL string
}
// NewVerifier instantiates a verifier with sane defaults.
func NewVerifier(conf *config.Config) *Verifier {
v := &Verifier{
conf: conf,
httpClient: &http.Client{Timeout: 10 * time.Second},
now: time.Now,
}
if conf != nil {
v.cachePath = filepath.Join(conf.ConfigPath(), "jwks-cache.json")
}
_ = v.loadCache()
return v
}
// Prime ensures JWKS material is cached locally.
func (v *Verifier) Prime(ctx context.Context, jwksURL string) error {
_, err := v.keysForURL(ctx, jwksURL, true)
return err
}
// VerifyToken validates a JWT against the expected claims and returns decoded claims.
func (v *Verifier) VerifyToken(ctx context.Context, tokenString string, expected ExpectedClaims) (*Claims, error) {
if v == nil {
return nil, errors.New("jwt: verifier not initialized")
}
if strings.TrimSpace(tokenString) == "" {
return nil, errors.New("jwt: token is empty")
}
if strings.TrimSpace(expected.Issuer) == "" {
return nil, errors.New("jwt: expected issuer required")
}
if strings.TrimSpace(expected.Audience) == "" {
return nil, errors.New("jwt: expected audience required")
}
if len(expected.Scope) == 0 {
return nil, errors.New("jwt: expected scope required")
}
url := strings.TrimSpace(expected.JWKSURL)
if url == "" && v.conf != nil {
url = strings.TrimSpace(v.conf.JWKSUrl())
}
if url == "" {
return nil, errors.New("jwt: jwks url not configured")
}
leeway := 60 * time.Second
if v.conf != nil && v.conf.JWTLeeway() > 0 {
leeway = time.Duration(v.conf.JWTLeeway()) * time.Second
}
parser := gojwt.NewParser(
gojwt.WithLeeway(leeway),
gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}),
gojwt.WithIssuer(expected.Issuer),
gojwt.WithAudience(expected.Audience),
)
claims := &Claims{}
keyFunc := func(token *gojwt.Token) (interface{}, error) {
kid, _ := token.Header["kid"].(string)
if kid == "" {
return nil, errors.New("jwt: missing kid header")
}
pk, err := v.publicKeyForKid(ctx, url, kid, false)
if errors.Is(err, errKeyNotFound) {
pk, err = v.publicKeyForKid(ctx, url, kid, true)
}
if err != nil {
return nil, err
}
return pk, nil
}
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
if claims.IssuedAt == nil || claims.ExpiresAt == nil {
return nil, errors.New("jwt: missing temporal claims")
}
if ttl := claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time); ttl > MaxTokenTTL {
return nil, errors.New("jwt: token ttl exceeds maximum")
}
scopeSet := map[string]struct{}{}
for _, s := range strings.Fields(claims.Scope) {
scopeSet[s] = struct{}{}
}
for _, req := range expected.Scope {
if _, ok := scopeSet[req]; !ok {
return nil, fmt.Errorf("jwt: missing scope %s", req)
}
}
return claims, nil
}
func (v *Verifier) publicKeyForKid(ctx context.Context, url, kid string, force bool) (ed25519.PublicKey, error) {
keys, err := v.keysForURL(ctx, url, force)
if err != nil {
return nil, err
}
for _, k := range keys {
if k.Kid != kid {
continue
}
raw, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("jwt: invalid public key length %d", len(raw))
}
pk := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(pk, raw)
return pk, nil
}
return nil, errKeyNotFound
}
func (v *Verifier) keysForURL(ctx context.Context, url string, force bool) ([]PublicJWK, error) {
v.mu.Lock()
defer v.mu.Unlock()
ttl := 300 * time.Second
if v.conf != nil && v.conf.JWKSCacheTTL() > 0 {
ttl = time.Duration(v.conf.JWKSCacheTTL()) * time.Second
}
if !force && v.cache.URL == url && len(v.cache.Keys) > 0 {
age := v.now().Unix() - v.cache.FetchedAt
if age >= 0 && time.Duration(age)*time.Second <= ttl {
return append([]PublicJWK(nil), v.cache.Keys...), nil
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if v.cache.URL == url && v.cache.ETag != "" {
req.Header.Set("If-None-Match", v.cache.ETag)
}
resp, err := v.httpClient.Do(req)
if err != nil {
if v.cache.URL == url && len(v.cache.Keys) > 0 {
return append([]PublicJWK(nil), v.cache.Keys...), nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
v.cache.FetchedAt = v.now().Unix()
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), v.cache.Keys...), nil
}
if resp.StatusCode != http.StatusOK {
if v.cache.URL == url && len(v.cache.Keys) > 0 {
return append([]PublicJWK(nil), v.cache.Keys...), nil
}
return nil, fmt.Errorf("jwt: jwks fetch failed: %s", resp.Status)
}
var body JWKS
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
if len(body.Keys) == 0 {
return nil, errors.New("jwt: jwks contains no keys")
}
v.cache = cacheEntry{
URL: url,
ETag: resp.Header.Get("ETag"),
Keys: append([]PublicJWK(nil), body.Keys...),
FetchedAt: v.now().Unix(),
}
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), body.Keys...), nil
}
func (v *Verifier) loadCache() error {
if v.cachePath == "" || !fs.FileExists(v.cachePath) {
return nil
}
b, err := os.ReadFile(v.cachePath)
if err != nil || len(b) == 0 {
return err
}
var entry cacheEntry
if err := json.Unmarshal(b, &entry); err != nil {
return err
}
v.cache = entry
return nil
}
func (v *Verifier) saveCacheLocked() error {
if v.cachePath == "" {
return nil
}
if err := fs.MkdirAll(filepath.Dir(v.cachePath)); err != nil {
return err
}
data, err := json.Marshal(v.cache)
if err != nil {
return err
}
return os.WriteFile(v.cachePath, data, fs.ModeSecretFile)
}

View File

@@ -0,0 +1,138 @@
package jwt
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
gojwt "github.com/golang-jwt/jwt/v5"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestVerifierPrimeAndVerify(t *testing.T) {
portalCfg := cfg.NewTestConfig("jwt-verifier-portal")
clusterUUID := rnd.UUIDv7()
portalCfg.Options().ClusterUUID = clusterUUID
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
etag := `"v1"`
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(jwksBytes)
}))
defer server.Close()
nodeCfg := cfg.NewTestConfig("jwt-verifier-node")
nodeCfg.SetJWKSUrl(server.URL + "/.well-known/jwks.json")
nodeCfg.Options().ClusterUUID = clusterUUID
nodeUUID := nodeCfg.NodeUUID()
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Now().UTC() }
spec := ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", nodeUUID),
Scope: []string{"cluster", "vision"},
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
verifier := NewVerifier(nodeCfg)
ctx := context.Background()
require.NoError(t, verifier.Prime(ctx, nodeCfg.JWKSUrl()))
require.Equal(t, 1, requestCount)
claims, err := verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, spec.Subject, claims.Subject)
require.Contains(t, claims.Scope, "cluster")
// Force cache refresh by expiring entry and verify 304 handling.
verifier.mu.Lock()
verifier.cache.FetchedAt -= 1000
verifier.mu.Unlock()
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, 2, requestCount)
// Missing scope should fail.
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster", "unknown"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.Error(t, err)
}
func TestIssuerClampTTL(t *testing.T) {
portalCfg := cfg.NewTestConfig("jwt-issuer-ttl")
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Unix(0, 0) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Unix(1000, 0) }
spec := ClaimsSpec{
Issuer: "portal:test",
Subject: "portal:client",
Audience: "node:test",
Scope: []string{"cluster"},
TTL: 7200 * time.Second,
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
parsed := &Claims{}
parser := gojwt.NewParser(gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}), gojwt.WithoutClaimsValidation())
_, err = parser.ParseWithClaims(token, parsed, func(token *gojwt.Token) (interface{}, error) {
key, _ := mgr.ActiveKey()
return key.PublicKey, nil
})
require.NoError(t, err)
ttl := parsed.ExpiresAt.Time.Sub(parsed.IssuedAt.Time)
require.Equal(t, MaxTokenTTL, ttl)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -20,5 +21,8 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -99,12 +99,12 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
} }
} }
body := map[string]interface{}{ payload := cluster.RegisterRequest{
"nodeName": name, NodeName: name,
"rotate": rotateDatabase, RotateDatabase: rotateDatabase,
"rotateSecret": rotateSecret, RotateSecret: rotateSecret,
} }
b, _ := json.Marshal(body) b, _ := json.Marshal(payload)
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register" url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse var resp cluster.RegisterResponse

View File

@@ -85,28 +85,28 @@ func clusterRegisterAction(ctx *cli.Context) error {
} }
site := conf.SiteUrl() site := conf.SiteUrl()
body := map[string]interface{}{ payload := cluster.RegisterRequest{
"nodeName": name, NodeName: name,
"nodeRole": nodeRole, NodeRole: nodeRole,
"labels": parseLabelSlice(ctx.StringSlice("label")), Labels: parseLabelSlice(ctx.StringSlice("label")),
"advertiseUrl": advertise, AdvertiseUrl: advertise,
"rotate": ctx.Bool("rotate"), RotateDatabase: ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"), RotateSecret: ctx.Bool("rotate-secret"),
} }
// If we already have client credentials (e.g., re-register), include them so the // If we already have client credentials (e.g., re-register), include them so the
// portal can verify and authorize UUID/name moves or metadata updates. // portal can verify and authorize UUID/name moves or metadata updates.
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" { if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" {
body["clientId"] = id payload.ClientID = id
body["clientSecret"] = secret payload.ClientSecret = secret
} }
if site != "" && site != advertise { if site != "" && site != advertise {
body["siteUrl"] = site payload.SiteUrl = site
} }
b, _ := json.Marshal(body) b, _ := json.Marshal(payload)
if ctx.Bool("dry-run") { if ctx.Bool("dry-run") {
if ctx.Bool("json") { if ctx.Bool("json") {
out := map[string]any{"portalUrl": portalURL, "payload": body} out := map[string]any{"portalUrl": portalURL, "payload": payload}
jb, _ := json.Marshal(out) jb, _ := json.Marshal(out)
fmt.Println(string(jb)) fmt.Println(string(jb))
} else { } else {
@@ -116,19 +116,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
fmt.Println("(derived defaults were used where flags were omitted)") fmt.Println("(derived defaults were used where flags were omitted)")
} }
fmt.Printf("Advertise: %s\n", advertise) fmt.Printf("Advertise: %s\n", advertise)
if v, ok := body["siteUrl"].(string); ok && v != "" { if payload.SiteUrl != "" {
fmt.Printf("Site URL: %s\n", v) fmt.Printf("Site URL: %s\n", payload.SiteUrl)
} }
// Warn if non-HTTPS on public host; server will enforce too. // Warn if non-HTTPS on public host; server will enforce too.
if warnInsecurePublicURL(advertise) { if warnInsecurePublicURL(advertise) {
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).") fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")
} }
if v, ok := body["siteUrl"].(string); ok && v != "" && warnInsecurePublicURL(v) { if payload.SiteUrl != "" && warnInsecurePublicURL(payload.SiteUrl) {
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).") fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
} }
// Single-line summary for quick operator scan // Single-line summary for quick operator scan
if v, ok := body["siteUrl"].(string); ok && v != "" { if payload.SiteUrl != "" {
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, v) fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, payload.SiteUrl)
} else { } else {
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise) fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
} }

View File

@@ -151,7 +151,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
} }
// Read payload to assert rotate flags // Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool() rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect DB rotation only // Expect DB rotation only
if !rotate || rotateSecret { if !rotate || rotateSecret {
@@ -203,7 +203,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool() rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool() rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect secret-only rotation // Expect secret-only rotation
if rotate || !rotateSecret { if rotate || !rotateSecret {
@@ -422,7 +422,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() { if !gjson.GetBytes(b, "rotateDatabase").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
} }
@@ -463,7 +463,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
return return
} }
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() { if gjson.GetBytes(b, "rotateDatabase").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
} }

View File

@@ -23,6 +23,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry" reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
@@ -95,7 +96,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Create a registry node via FileRegistry. // Create a registry node via FileRegistry.
r, err := reg.NewClientRegistryWithConfig(c) r, err := reg.NewClientRegistryWithConfig(c)
assert.NoError(t, err) assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}} n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
// nodes ls (JSON) // nodes ls (JSON)

View File

@@ -42,7 +42,7 @@ func TestMain(m *testing.M) {
// Run unit tests. // Run unit tests.
code := m.Run() code := m.Run()
// Purge local SQLite test artifacts created during this package's tests. // Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)

View File

@@ -210,6 +210,43 @@ func (c *Config) NodeClientSecret() string {
} }
} }
// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
// persist this URL from the portal's register response, which derives it from SiteUrl;
// manual overrides are only required for custom deployments.
func (c *Config) JWKSUrl() string {
return strings.TrimSpace(c.options.JWKSUrl)
}
// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
func (c *Config) SetJWKSUrl(url string) {
if c == nil || c.options == nil {
return
}
c.options.JWKSUrl = strings.TrimSpace(url)
}
// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
func (c *Config) JWKSCacheTTL() int {
if c.options.JWKSCacheTTL <= 0 {
return 300
}
if c.options.JWKSCacheTTL > 3600 {
return 3600
}
return c.options.JWKSCacheTTL
}
// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
func (c *Config) JWTLeeway() int {
if c.options.JWTLeeway <= 0 {
return 60
}
if c.options.JWTLeeway > 300 {
return 300
}
return c.options.JWTLeeway
}
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]). // AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
func (c *Config) AdvertiseUrl() string { func (c *Config) AdvertiseUrl() string {
if c.options.AdvertiseUrl != "" { if c.options.AdvertiseUrl != "" {

View File

@@ -72,6 +72,17 @@ func TestConfig_Cluster(t *testing.T) {
assert.True(t, c.IsPortal()) assert.True(t, c.IsPortal())
c.Options().NodeRole = "" c.Options().NodeRole = ""
}) })
t.Run("JWKSUrlSetter", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JWKSUrl = ""
assert.Equal(t, "", c.JWKSUrl())
c.SetJWKSUrl(" https://portal.example/.well-known/jwks.json ")
assert.Equal(t, "https://portal.example/.well-known/jwks.json", c.JWKSUrl())
c.SetJWKSUrl("")
assert.Equal(t, "", c.JWKSUrl())
})
t.Run("Paths", func(t *testing.T) { t.Run("Paths", func(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())

View File

@@ -31,7 +31,7 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Purge local SQLite test artifacts created during this package's tests. // Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)

View File

@@ -720,6 +720,23 @@ var Flags = CliFlags{
EnvVars: EnvVars("NODE_CLIENT_SECRET"), EnvVars: EnvVars("NODE_CLIENT_SECRET"),
Hidden: true, Hidden: true,
}}, { }}, {
Flag: &cli.StringFlag{
Name: "jwks-url",
Usage: "JWKS endpoint `URL` provided by the cluster portal for JWT verification",
EnvVars: EnvVars("JWKS_URL"),
}}, {
Flag: &cli.IntFlag{
Name: "jwks-cache-ttl",
Usage: "JWKS cache lifetime in `SECONDS` (default 300, max 3600)",
Value: 300,
EnvVars: EnvVars("JWKS_CACHE_TTL"),
}}, {
Flag: &cli.IntFlag{
Name: "jwt-leeway",
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
Value: 60,
EnvVars: EnvVars("JWT_LEEWAY"),
}}, {
Flag: &cli.StringFlag{ Flag: &cli.StringFlag{
Name: "advertise-url", Name: "advertise-url",
Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])", Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])",

View File

@@ -152,6 +152,9 @@ type Options struct {
NodeRole string `yaml:"-" json:"-" flag:"node-role"` NodeRole string `yaml:"-" json:"-" flag:"node-role"`
NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"` NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"`
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"` NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"` AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"` HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"` HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`

View File

@@ -188,6 +188,9 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"node-uuid", c.NodeUUID()}, {"node-uuid", c.NodeUUID()},
{"node-client-id", c.NodeClientID()}, {"node-client-id", c.NodeClientID()},
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))}, {"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
{"jwks-url", c.JWKSUrl()},
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
{"advertise-url", c.AdvertiseUrl()}, {"advertise-url", c.AdvertiseUrl()},
// Proxy Servers. // Proxy Servers.

View File

@@ -24,7 +24,7 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Purge local SQLite test artifacts created during this package's tests. // Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -21,5 +22,8 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -24,4 +24,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package apple package apple

View File

@@ -24,4 +24,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package intel package intel

View File

@@ -24,4 +24,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package nvidia package nvidia

View File

@@ -24,4 +24,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package v4l package v4l

View File

@@ -24,4 +24,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/> <https://docs.photoprism.app/developer-guide/>
*/ */
package vaapi package vaapi

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -25,5 +26,8 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -0,0 +1,58 @@
package get
import (
"sync"
"github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/pkg/clean"
)
var (
onceJWTManager sync.Once
onceJWTIssuer sync.Once
)
func initJWTManager() {
conf := Config()
if conf == nil || !conf.IsPortal() {
return
}
manager, err := jwt.NewManager(conf)
if err != nil {
log.Warnf("jwt: manager init failed (%s)", clean.Error(err))
return
}
if _, err := manager.EnsureActiveKey(); err != nil {
log.Warnf("jwt: ensure signing key failed (%s)", clean.Error(err))
}
services.JWTManager = manager
}
// JWTManager returns the portal key manager; nil on nodes.
func JWTManager() *jwt.Manager {
onceJWTManager.Do(initJWTManager)
return services.JWTManager
}
func initJWTIssuer() {
manager := JWTManager()
if manager == nil {
return
}
services.JWTIssuer = jwt.NewIssuer(manager)
}
// JWTIssuer returns the portal JWT issuer helper; nil on nodes.
func JWTIssuer() *jwt.Issuer {
onceJWTIssuer.Do(initJWTIssuer)
return services.JWTIssuer
}
// JWTVerifier returns a verifier bound to the current config.
func JWTVerifier() *jwt.Verifier {
conf := Config()
if conf == nil {
return nil
}
return jwt.NewVerifier(conf)
}

View File

@@ -27,6 +27,7 @@ package get
import ( import (
gc "github.com/patrickmn/go-cache" gc "github.com/patrickmn/go-cache"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/oidc" "github.com/photoprism/photoprism/internal/auth/oidc"
"github.com/photoprism/photoprism/internal/auth/session" "github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
@@ -54,6 +55,8 @@ var services struct {
Thumbs *photoprism.Thumbs Thumbs *photoprism.Thumbs
Session *session.Session Session *session.Session
OIDC *oidc.Client OIDC *oidc.Client
JWTManager *clusterjwt.Manager
JWTIssuer *clusterjwt.Issuer
} }
func SetConfig(c *config.Config) { func SetConfig(c *config.Config) {

View File

@@ -20,7 +20,7 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Purge local SQLite test artifacts created during this package's tests. // Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)

View File

@@ -1,12 +1,17 @@
package server package server
import ( import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/wellknown" "github.com/photoprism/photoprism/internal/server/wellknown"
"github.com/photoprism/photoprism/pkg/service/http/header"
) )
// registerWellknownRoutes adds "/.well-known/" service discovery routes. // registerWellknownRoutes adds "/.well-known/" service discovery routes.
@@ -20,4 +25,32 @@ func registerWellknownRoutes(router *gin.Engine, conf *config.Config) {
router.Any(conf.BaseUri("/.well-known/openid-configuration"), func(c *gin.Context) { router.Any(conf.BaseUri("/.well-known/openid-configuration"), func(c *gin.Context) {
c.JSON(http.StatusOK, wellknown.NewOpenIDConfiguration(conf)) c.JSON(http.StatusOK, wellknown.NewOpenIDConfiguration(conf))
}) })
// Registers the "/.well-known/jwks.json" endpoint for cluster JWT verification.
router.GET(conf.BaseUri("/.well-known/jwks.json"), func(c *gin.Context) {
if !conf.IsPortal() {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
manager := get.JWTManager()
if manager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "jwks unavailable"})
return
}
jwks := manager.JWKS()
payload, err := json.Marshal(jwks)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "jwks marshal failed"})
return
}
sum := sha256.Sum256(payload)
etag := fmt.Sprintf("\"%x\"", sum[:8])
ttl := conf.JWKSCacheTTL()
if ttl <= 0 {
ttl = 300
}
c.Header(header.CacheControl, fmt.Sprintf("max-age=%d, public", ttl))
c.Header(header.ETag, etag)
c.Data(http.StatusOK, header.ContentTypeJson, payload)
})
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -29,5 +30,8 @@ func TestMain(m *testing.M) {
// Run unit tests. // Run unit tests.
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -2,6 +2,7 @@ package wellknown
import ( import (
"fmt" "fmt"
"strings"
"github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
@@ -37,13 +38,19 @@ type OpenIDConfiguration struct {
// NewOpenIDConfiguration creates a service discovery endpoint response based on the config provided. // NewOpenIDConfiguration creates a service discovery endpoint response based on the config provided.
func NewOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration { func NewOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration {
jwksPath := conf.BaseUri("/.well-known/jwks.json")
if jwksPath == "" {
jwksPath = "/.well-known/jwks.json"
}
jwksURL := strings.TrimRight(conf.SiteUrl(), "/") + jwksPath
return &OpenIDConfiguration{ return &OpenIDConfiguration{
Issuer: conf.SiteUrl(), Issuer: conf.SiteUrl(),
AuthorizationEndpoint: fmt.Sprintf("%sapi/v1/oauth/authorize", conf.SiteUrl()), AuthorizationEndpoint: fmt.Sprintf("%sapi/v1/oauth/authorize", conf.SiteUrl()),
TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()), TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()),
UserinfoEndpoint: fmt.Sprintf("%sapi/v1/oauth/userinfo", conf.SiteUrl()), UserinfoEndpoint: fmt.Sprintf("%sapi/v1/oauth/userinfo", conf.SiteUrl()),
RegistrationEndpoint: "", RegistrationEndpoint: "",
JwksUri: "", JwksUri: jwksURL,
ResponseTypesSupported: OAuthResponseTypes, ResponseTypesSupported: OAuthResponseTypes,
ResponseModesSupported: []string{}, ResponseModesSupported: []string{},
GrantTypesSupported: OAuthGrantTypes, GrantTypesSupported: OAuthGrantTypes,

View File

@@ -16,6 +16,7 @@ func TestOpenIDConfiguration(t *testing.T) {
assert.IsType(t, &OpenIDConfiguration{}, result) assert.IsType(t, &OpenIDConfiguration{}, result)
assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint) assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint)
assert.Equal(t, "http://localhost:2342/api/v1/oauth/revoke", result.RevocationEndpoint) assert.Equal(t, "http://localhost:2342/api/v1/oauth/revoke", result.RevocationEndpoint)
assert.Equal(t, "http://localhost:2342/.well-known/jwks.json", result.JwksUri)
assert.Equal(t, OAuthResponseTypes, result.ResponseTypesSupported) assert.Equal(t, OAuthResponseTypes, result.ResponseTypesSupported)
assert.Equal(t, OAuthRevocationEndpointAuthMethods, result.RevocationEndpointAuthMethodsSupported) assert.Equal(t, OAuthRevocationEndpointAuthMethods, result.RevocationEndpointAuthMethodsSupported)
}) })

View File

@@ -0,0 +1,18 @@
package wellknown
import (
"os"
"testing"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -1,6 +1,7 @@
package instance package instance
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -17,6 +18,7 @@ import (
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service/cluster" "github.com/photoprism/photoprism/internal/service/cluster"
@@ -117,27 +119,27 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
opts.DatabaseDSN == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" && opts.DatabaseDSN == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" &&
c.DatabasePassword() == "" c.DatabasePassword() == ""
payload := map[string]interface{}{ payload := cluster.RegisterRequest{
"nodeName": c.NodeName(), NodeName: c.NodeName(),
"nodeUUID": c.NodeUUID(), NodeUUID: c.NodeUUID(),
"nodeRole": cluster.RoleInstance, // JSON wire format is string NodeRole: cluster.RoleInstance,
"advertiseUrl": c.AdvertiseUrl(), AdvertiseUrl: c.AdvertiseUrl(),
} }
// Include client credentials when present so the Portal can verify re-registration // Include client credentials when present so the Portal can verify re-registration
// and authorize UUID/name changes. // and authorize UUID/name changes.
if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" { if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" {
payload["clientId"] = id payload.ClientID = id
payload["clientSecret"] = secret payload.ClientSecret = secret
} }
// Include siteUrl when it differs from advertiseUrl; server will validate/normalize. // Include siteUrl when it differs from advertiseUrl; server will validate/normalize.
if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() { if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() {
payload["siteUrl"] = su payload.SiteUrl = su
} }
if wantRotateDatabase { if wantRotateDatabase {
// Align with API: request database rotation/creation on (re)register. // Align with API: request database rotation/creation on (re)register.
payload["rotateDatabase"] = true payload.RotateDatabase = true
} }
bodyBytes, _ := json.Marshal(payload) bodyBytes, _ := json.Marshal(payload)
@@ -171,6 +173,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
if err := persistRegistration(c, &r, wantRotateDatabase); err != nil { if err := persistRegistration(c, &r, wantRotateDatabase); err != nil {
return err return err
} }
primeJWKS(c, r.JWKSUrl)
if resp.StatusCode == http.StatusCreated { if resp.StatusCode == http.StatusCreated {
log.Infof("cluster: registered as %s (%d)", clean.LogQuote(r.Node.Name), resp.StatusCode) log.Infof("cluster: registered as %s (%d)", clean.LogQuote(r.Node.Name), resp.StatusCode)
} else { } else {
@@ -226,8 +229,13 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
updates["NodeClientSecret"] = r.Secrets.ClientSecret updates["NodeClientSecret"] = r.Secrets.ClientSecret
} }
if url := strings.TrimSpace(r.JWKSUrl); url != "" {
updates["JWKSUrl"] = url
c.SetJWKSUrl(url)
}
// Persist NodeUUID from portal response if provided and not set locally. // Persist NodeUUID from portal response if provided and not set locally.
if r.Node.UUID != "" && c.Options().NodeUUID == "" { if r.Node.UUID != "" && c.NodeUUID() == "" {
updates["NodeUUID"] = r.Node.UUID updates["NodeUUID"] = r.Node.UUID
} }
@@ -267,6 +275,22 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
return nil return nil
} }
func primeJWKS(c *config.Config, url string) {
if c == nil {
return
}
url = strings.TrimSpace(url)
if url == "" {
return
}
verifier := clusterjwt.NewVerifier(c)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := verifier.Prime(ctx, url); err != nil {
log.Debugf("cluster: jwks prime skipped (%s)", clean.Error(err))
}
}
func hasDBUpdate(m map[string]interface{}) bool { func hasDBUpdate(m map[string]interface{}) bool {
if _, ok := m["DatabaseDSN"]; ok { if _, ok := m["DatabaseDSN"]; ok {
return true return true

View File

@@ -29,6 +29,7 @@ func TestInitConfig_NoPortal_NoOp(t *testing.T) {
func TestRegister_PersistSecretAndDB(t *testing.T) { func TestRegister_PersistSecretAndDB(t *testing.T) {
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir()) t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
// Fake Portal server. // Fake Portal server.
var jwksURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/cluster/nodes/register": case "/api/v1/cluster/nodes/register":
@@ -39,6 +40,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
Node: cluster.Node{Name: "pp-node-01"}, Node: cluster.Node{Name: "pp-node-01"},
UUID: rnd.UUID(), UUID: rnd.UUID(),
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL,
Database: cluster.RegisterDatabase{ Database: cluster.RegisterDatabase{
Driver: config.MySQL, Driver: config.MySQL,
Host: "db.local", Host: "db.local",
@@ -57,6 +59,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
} }
})) }))
jwksURL = srv.URL + "/.well-known/jwks.json"
defer srv.Close() defer srv.Close()
c := config.NewTestConfig("bootstrap-reg") c := config.NewTestConfig("bootstrap-reg")
@@ -78,6 +81,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
// DSN branch should be preferred and persisted. // DSN branch should be preferred and persisted.
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db") assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver) assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
} }
func TestThemeInstall_Missing(t *testing.T) { func TestThemeInstall_Missing(t *testing.T) {
@@ -90,12 +94,13 @@ func TestThemeInstall_Missing(t *testing.T) {
_ = zw.Close() _ = zw.Close()
// Fake Portal server (register -> oauth token -> theme) // Fake Portal server (register -> oauth token -> theme)
var jwksURL2 string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/cluster/nodes/register": case "/api/v1/cluster/nodes/register":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token // Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}}) _ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
case "/api/v1/oauth/token": case "/api/v1/oauth/token":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"}) _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
@@ -107,6 +112,7 @@ func TestThemeInstall_Missing(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
} }
})) }))
jwksURL2 = srv.URL + "/.well-known/jwks.json"
defer srv.Close() defer srv.Close()
c := config.NewTestConfig("bootstrap-theme") c := config.NewTestConfig("bootstrap-theme")
@@ -133,6 +139,7 @@ func TestThemeInstall_Missing(t *testing.T) {
func TestRegister_SQLite_NoDBPersist(t *testing.T) { func TestRegister_SQLite_NoDBPersist(t *testing.T) {
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir()) t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
// Portal responds with DB DSN, but local driver is SQLite → must not persist DB. // Portal responds with DB DSN, but local driver is SQLite → must not persist DB.
var jwksURL3 string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/cluster/nodes/register": case "/api/v1/cluster/nodes/register":
@@ -141,6 +148,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
resp := cluster.RegisterResponse{ resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"}, Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL3,
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"}, Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
} }
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
@@ -148,6 +156,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
} }
})) }))
jwksURL3 = srv.URL + "/.well-known/jwks.json"
defer srv.Close() defer srv.Close()
c := config.NewTestConfig("bootstrap-sqlite") c := config.NewTestConfig("bootstrap-sqlite")
@@ -165,6 +174,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
assert.Equal(t, "SECRET", c.NodeClientSecret()) assert.Equal(t, "SECRET", c.NodeClientSecret())
assert.Equal(t, config.SQLite3, c.DatabaseDriver()) assert.Equal(t, config.SQLite3, c.DatabaseDriver())
assert.Equal(t, origDSN, c.Options().DatabaseDSN) assert.Equal(t, origDSN, c.Options().DatabaseDSN)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
} }
func TestRegister_404_NoRetry(t *testing.T) { func TestRegister_404_NoRetry(t *testing.T) {

View File

@@ -9,7 +9,11 @@ import (
// TestMain ensures SQLite test DB artifacts are purged after the suite runs. // TestMain ensures SQLite test DB artifacts are purged after the suite runs.
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -29,21 +29,22 @@ type Credentials struct {
func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName string, rotate bool) (Credentials, bool, error) { func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName string, rotate bool) (Credentials, bool, error) {
out := Credentials{} out := Credentials{}
// Normalize provisioner driver to lower-case to accept variants like "MySQL"/"MariaDB". // Normalize the configured admin driver locally so we accept variants like "MySQL"/"MariaDB"
DatabaseDriver = strings.ToLower(DatabaseDriver) // without mutating the global setting (keeps config reporting consistent).
driver := strings.ToLower(DatabaseDriver)
switch DatabaseDriver { switch driver {
case config.MySQL, config.MariaDB: case config.MySQL, config.MariaDB:
// ok // ok
case config.SQLite3, config.Postgres: case config.SQLite3, config.Postgres:
return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning") return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning")
default: default:
// Driver is configured externally for the provisioner (decoupled from app config). // Driver is configured externally for the provisioner (decoupled from app config).
return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", DatabaseDriver) return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", driver)
} }
// Compute deterministic names and a candidate password. // Compute deterministic names and a candidate password.
dbName, dbUser, dbPass := GenerateCreds(conf, nodeUUID, nodeName) dbName, dbUser, dbPass := GenerateCredentials(conf, nodeUUID, nodeName)
// Extra safety: enforce allowed identifier charset. // Extra safety: enforce allowed identifier charset.
if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) { if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) {
@@ -122,10 +123,10 @@ func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName
out.Port = DatabasePort out.Port = DatabasePort
out.Name = dbName out.Name = dbName
out.User = dbUser out.User = dbUser
out.Driver = DatabaseDriver out.Driver = driver
if out.Password != "" { if out.Password != "" {
out.DSN = BuildDSN(DatabaseDriver, out.Host, out.Port, out.User, out.Password, out.Name) out.DSN = BuildDSN(driver, out.Host, out.Port, out.User, out.Password, out.Name)
} }
return out, created, nil return out, created, nil

View File

@@ -103,6 +103,7 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
DatabaseDriver = "PostGreS" DatabaseDriver = "PostGreS"
_, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false) _, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false)
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "PostGreS", DatabaseDriver)
// Unknown driver should return the unsupported error including normalized name. // Unknown driver should return the unsupported error including normalized name.
DatabaseDriver = "TiDB" DatabaseDriver = "TiDB"
@@ -110,4 +111,5 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
if assert.Error(t, err) { if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unsupported auto-provisioning database driver: tidb") assert.Contains(t, err.Error(), "unsupported auto-provisioning database driver: tidb")
} }
assert.Equal(t, "TiDB", DatabaseDriver)
} }

View File

@@ -13,11 +13,17 @@ import (
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
// ProvisionDSN specifies database auto-provisioning DSN, for example: // ProvisionDSN specifies the admin DSN used for auto-provisioning, for example:
// root:insecure@tcp(127.0.0.1:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true // root:insecure@tcp(127.0.0.1:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true
var ProvisionDSN = "root:photoprism@tcp(mariadb:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" var ProvisionDSN = "root:photoprism@tcp(mariadb:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true"
// DatabaseHost is the hostname of the admin server used for provisioning operations.
var DatabaseHost = "mariadb" var DatabaseHost = "mariadb"
// DatabasePort is the port of the admin server used for provisioning operations.
var DatabasePort = 4001 var DatabasePort = 4001
// DatabaseDriver indicates the SQL driver used for provisioning (independent from the app DB driver).
var DatabaseDriver = "mysql" var DatabaseDriver = "mysql"
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@@ -24,10 +24,10 @@ const (
dbMax = 64 dbMax = 64
) )
// GenerateCreds computes deterministic database name and user for a node under the given portal // GenerateCredentials computes deterministic database name and user for a node under the given portal
// plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes // plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes
// if the cluster UUID or node UUID changes. // if the cluster UUID or node UUID changes.
func GenerateCreds(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) { func GenerateCredentials(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
clusterUUID := conf.ClusterUUID() clusterUUID := conf.ClusterUUID()
// Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID. // Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID.
@@ -59,6 +59,9 @@ func BuildDSN(driver, host string, port int, user, pass, name string) string {
} }
} }
// hmacBase32 returns a lowercase Base32 (no padding) encoded HMAC-SHA256 digest
// derived from the provided key and data. It is used to generate deterministic
// suffixes for database identifiers while keeping the resulting string URL/identifier safe.
func hmacBase32(key, data string) string { func hmacBase32(key, data string) string {
mac := hmac.New(sha256.New, []byte(key)) mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write([]byte(data)) _, _ = mac.Write([]byte(data))

View File

@@ -1,6 +1,7 @@
package provisioner package provisioner
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -8,13 +9,13 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
) )
func TestGenerateCreds_StabilityAndBudgets(t *testing.T) { func TestGenerateCredentials_StabilityAndBudgets(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
// Fix the cluster UUID via options to ensure determinism. // Fix the cluster UUID via options to ensure determinism.
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
db1, user1, pass1 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01") db1, user1, pass1 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, pass2 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01") db2, user2, pass2 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
// Names stable; password random. // Names stable; password random.
assert.Equal(t, db1, db2) assert.Equal(t, db1, db2)
@@ -28,24 +29,24 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
assert.Contains(t, user1, "photoprism_") assert.Contains(t, user1, "photoprism_")
} }
func TestGenerateCreds_DifferentPortal(t *testing.T) { func TestGenerateCredentials_DifferentPortal(t *testing.T) {
c1 := config.NewConfig(config.CliTestContext()) c1 := config.NewConfig(config.CliTestContext())
c2 := config.NewConfig(config.CliTestContext()) c2 := config.NewConfig(config.CliTestContext())
c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222" c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222"
db1, user1, _ := GenerateCreds(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01") db1, user1, _ := GenerateCredentials(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, _ := GenerateCreds(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01") db2, user2, _ := GenerateCredentials(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01")
assert.NotEqual(t, db1, db2) assert.NotEqual(t, db1, db2)
assert.NotEqual(t, user1, user2) assert.NotEqual(t, user1, user2)
} }
func TestGenerateCreds_Truncation(t *testing.T) { func TestGenerateCredentials_Truncation(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111" c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets" longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets"
db, user, _ := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", longName) db, user, _ := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", longName)
assert.LessOrEqual(t, len(user), 32) assert.LessOrEqual(t, len(user), 32)
assert.LessOrEqual(t, len(db), 64) assert.LessOrEqual(t, len(db), 64)
@@ -58,7 +59,21 @@ func TestBuildDSN(t *testing.T) {
assert.Contains(t, dsn, "parseTime=true") assert.Contains(t, dsn, "parseTime=true")
} }
func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) { func TestHmacBase32_LowercaseDeterministic(t *testing.T) {
a := hmacBase32("k1", "data")
b := hmacBase32("k1", "data")
c := hmacBase32("k1", "other")
assert.Equal(t, a, b, "same key/data should produce identical digest")
assert.NotEqual(t, a, c, "different data should change the digest")
assert.NotZero(t, len(a))
assert.Equal(t, strings.ToLower(a), a, "digest must be lowercase")
for _, ch := range a {
assert.Contains(t, "abcdefghijklmnopqrstuvwxyz234567", string(ch))
}
}
func TestGetCredentials_SqliteRejected(t *testing.T) {
c := config.NewConfig(config.CliTestContext()) c := config.NewConfig(config.CliTestContext())
// Ensure we're on SQLite in tests. // Ensure we're on SQLite in tests.
if c.DatabaseDriver() != config.SQLite3 { if c.DatabaseDriver() != config.SQLite3 {

View File

@@ -26,7 +26,8 @@ func toNode(c *entity.Client) *Node {
if c == nil { if c == nil {
return nil return nil
} }
n := &Node{ n := &Node{}
n.Node = cluster.Node{
UUID: c.NodeUUID, UUID: c.NodeUUID,
Name: c.ClientName, Name: c.ClientName,
Role: c.ClientRole, Role: c.ClientRole,
@@ -43,10 +44,11 @@ func toNode(c *entity.Client) *Node {
} }
n.SiteUrl = data.SiteURL n.SiteUrl = data.SiteURL
if db := data.Database; db != nil { if db := data.Database; db != nil {
n.Database.Name = db.Name dest := n.ensureDatabase()
n.Database.User = db.User dest.Name = db.Name
n.Database.Driver = db.Driver dest.User = db.User
n.Database.RotatedAt = db.RotatedAt dest.Driver = db.Driver
dest.RotatedAt = db.RotatedAt
} }
n.RotatedAt = data.RotatedAt n.RotatedAt = data.RotatedAt
} }
@@ -126,14 +128,14 @@ func (r *ClientRegistry) Put(n *Node) error {
m.NodeUUID = n.UUID m.NodeUUID = n.UUID
} }
data.RotatedAt = n.RotatedAt data.RotatedAt = n.RotatedAt
if n.Database.Name != "" || n.Database.User != "" || n.Database.RotatedAt != "" { if db := n.Database; db != nil && (db.Name != "" || db.User != "" || db.RotatedAt != "") {
if data.Database == nil { if data.Database == nil {
data.Database = &entity.ClientDatabase{} data.Database = &entity.ClientDatabase{}
} }
data.Database.Name = n.Database.Name data.Database.Name = db.Name
data.Database.User = n.Database.User data.Database.User = db.User
data.Database.Driver = n.Database.Driver data.Database.Driver = db.Driver
data.Database.RotatedAt = n.Database.RotatedAt data.Database.RotatedAt = db.RotatedAt
} }
m.SetData(data) m.SetData(data)
@@ -165,9 +167,10 @@ func (r *ClientRegistry) Put(n *Node) error {
} }
n.SiteUrl = data.SiteURL n.SiteUrl = data.SiteURL
if db := data.Database; db != nil { if db := data.Database; db != nil {
n.Database.Name = db.Name dest := n.ensureDatabase()
n.Database.User = db.User dest.Name = db.Name
n.Database.RotatedAt = db.RotatedAt dest.User = db.User
dest.RotatedAt = db.RotatedAt
} }
n.RotatedAt = data.RotatedAt n.RotatedAt = data.RotatedAt
} }

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -44,7 +45,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
assert.NoError(t, c.Init()) assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
n := &Node{Name: "pp-role", Role: "service"} n := &Node{Node: cluster.Node{Name: "pp-role", Role: "service"}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
got, err := r.FindByName("pp-role") got, err := r.FindByName("pp-role")
assert.NoError(t, err) assert.NoError(t, err)
@@ -52,7 +53,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
assert.Equal(t, "service", got.Role) assert.Equal(t, "service", got.Role)
} }
// Change to instance // Change to instance
upd := &Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"} upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"}}
assert.NoError(t, r.Put(upd)) assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-role") got2, err := r.FindByName("pp-role")
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -23,16 +24,19 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
// Create new node // Create new node
n := &Node{ n := &Node{
UUID: rnd.UUIDv7(), Node: cluster.Node{
Name: "pp-node-a", UUID: rnd.UUIDv7(),
Role: "instance", Name: "pp-node-a",
SiteUrl: "https://photos.example.com", Role: "instance",
AdvertiseUrl: "http://pp-node-a:2342", SiteUrl: "https://photos.example.com",
Labels: map[string]string{"env": "test"}, AdvertiseUrl: "http://pp-node-a:2342",
Labels: map[string]string{"env": "test"},
},
} }
n.Database.Name = "pp_db" db := n.ensureDatabase()
n.Database.User = "pp_user" db.Name = "pp_db"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) db.User = "pp_user"
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
n.RotatedAt = time.Now().UTC().Format(time.RFC3339) n.RotatedAt = time.Now().UTC().Format(time.RFC3339)
n.ClientSecret = rnd.ClientSecret() n.ClientSecret = rnd.ClientSecret()
@@ -49,8 +53,10 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
assert.Equal(t, "instance", got.Role) assert.Equal(t, "instance", got.Role)
assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl) assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl)
assert.Equal(t, "https://photos.example.com", got.SiteUrl) assert.Equal(t, "https://photos.example.com", got.SiteUrl)
assert.Equal(t, "pp_db", got.Database.Name) if assert.NotNil(t, got.Database) {
assert.Equal(t, "pp_user", got.Database.User) assert.Equal(t, "pp_db", got.Database.Name)
assert.Equal(t, "pp_user", got.Database.User)
}
assert.NotEmpty(t, got.CreatedAt) assert.NotEmpty(t, got.CreatedAt)
assert.NotEmpty(t, got.UpdatedAt) assert.NotEmpty(t, got.UpdatedAt)
// Secret is not persisted in plaintext // Secret is not persisted in plaintext
@@ -88,7 +94,7 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
} }
// Update labels and site URL via Put (upsert by id) // Update labels and site URL via Put (upsert by id)
upd := &Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"} upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"}}
assert.NoError(t, r.Put(upd)) assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-node-a") got2, err := r.FindByName("pp-node-a")
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -1,23 +1,20 @@
package registry package registry
import "github.com/photoprism/photoprism/internal/service/cluster"
// Node represents a registered cluster node (transport DTO inside registry package). // Node represents a registered cluster node (transport DTO inside registry package).
// It is used by both client-backed and (legacy) file-backed registries. // It embeds the public cluster.Node DTO so we have a single source of truth for fields.
// Additional internal-only metadata is stored alongside the embedded struct.
type Node struct { type Node struct {
UUID string `json:"uuid"` // primary identifier (UUID v7) cluster.Node
Name string `json:"name"` ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
Role string `json:"role"` RotatedAt string `json:"rotatedAt,omitempty"` // secret rotation timestamp
ClientID string `json:"clientId,omitempty"` // OAuth client identifier (legacy) }
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
SiteUrl string `json:"siteUrl,omitempty"` // ensureDatabase returns a writable NodeDatabase, creating one if missing.
AdvertiseUrl string `json:"advertiseUrl,omitempty"` func (n *Node) ensureDatabase() *cluster.NodeDatabase {
Labels map[string]string `json:"labels,omitempty"` if n.Node.Database == nil {
RotatedAt string `json:"rotatedAt,omitempty"` n.Node.Database = &cluster.NodeDatabase{}
CreatedAt string `json:"createdAt"` }
UpdatedAt string `json:"updatedAt"` return n.Node.Database
Database struct {
Name string `json:"name"`
User string `json:"user"`
Driver string `json:"driver,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"`
} `json:"database,omitempty"`
} }

View File

@@ -7,6 +7,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -20,13 +21,13 @@ func TestClientRegistry_ClientIDReuse_CannotHijackExistingUUID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
// Seed two independent nodes // Seed two independent nodes
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"} b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(a)) assert.NoError(t, r.Put(a))
assert.NoError(t, r.Put(b)) assert.NoError(t, r.Put(b))
// Attempt to update UUID=b while passing ClientID of a // Attempt to update UUID=b while passing ClientID of a
assert.NoError(t, r.Put(&Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"})) assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"}}))
// a stays attached to its original UUID and ClientID // a stays attached to its original UUID and ClientID
gotA, err := r.FindByNodeUUID(a.UUID) gotA, err := r.FindByNodeUUID(a.UUID)
@@ -56,12 +57,12 @@ func TestClientRegistry_ClientIDReuse_ChangesUUIDWhenTargetMissing(t *testing.T)
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
// Seed one node // Seed one node
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"} a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"}}
assert.NoError(t, r.Put(a)) assert.NoError(t, r.Put(a))
// Move the row to a new UUID by referencing the same ClientID and a new UUID // Move the row to a new UUID by referencing the same ClientID and a new UUID
newUUID := rnd.UUIDv7() newUUID := rnd.UUIDv7()
assert.NoError(t, r.Put(&Node{UUID: newUUID, ClientID: a.ClientID})) assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: newUUID, ClientID: a.ClientID}}))
// Old UUID no longer resolves // Old UUID no longer resolves
_, err := r.FindByNodeUUID(a.UUID) _, err := r.FindByNodeUUID(a.UUID)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -18,7 +19,7 @@ func TestClientRegistry_FindByClientID(t *testing.T) {
assert.NoError(t, c.Init()) assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
n := &Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()} n := &Node{Node: cluster.Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
got, err := r.FindByClientID(n.ClientID) got, err := r.FindByClientID(n.ClientID)
@@ -75,15 +76,15 @@ func TestClientRegistry_SwapNames_UUIDAuthoritative(t *testing.T) {
assert.NoError(t, c.Init()) assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"} b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(a)) assert.NoError(t, r.Put(a))
assert.NoError(t, r.Put(b)) assert.NoError(t, r.Put(b))
// Swap names via UUID-targeted updates // Swap names via UUID-targeted updates
assert.NoError(t, r.Put(&Node{UUID: a.UUID, Name: "pp-b"})) assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: a.UUID, Name: "pp-b"}}))
time.Sleep(1100 * time.Millisecond) time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(&Node{UUID: b.UUID, Name: "pp-a"})) assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, Name: "pp-a"}}))
// UUID lookups map to the correct updated names // UUID lookups map to the correct updated names
gotA, err := r.FindByNodeUUID(a.UUID) gotA, err := r.FindByNodeUUID(a.UUID)
@@ -121,11 +122,12 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
assert.NoError(t, c.Init()) assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
n := &Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"} n := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"}}
n.Database.Name = "photoprism_d123" db := n.ensureDatabase()
n.Database.User = "photoprism_u123" db.Name = "photoprism_d123"
n.Database.Driver = "mysql" db.User = "photoprism_u123"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) db.Driver = "mysql"
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
got, err := r.FindByNodeUUID(n.UUID) got, err := r.FindByNodeUUID(n.UUID)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -21,10 +22,10 @@ func TestClientRegistry_RotateSecretByUUID_LatestRow(t *testing.T) {
uuid := rnd.UUIDv7() uuid := rnd.UUIDv7()
// Create two entries for same NodeUUID; c2 will be latest // Create two entries for same NodeUUID; c2 will be latest
n1 := &Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"} n1 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"}}
assert.NoError(t, r.Put(n1)) assert.NoError(t, r.Put(n1))
time.Sleep(1100 * time.Millisecond) time.Sleep(1100 * time.Millisecond)
n2 := &Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"} n2 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"}}
assert.NoError(t, r.Put(n2)) assert.NoError(t, r.Put(n2))
// Rotate by UUID // Rotate by UUID

View File

@@ -9,14 +9,19 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
// TestMain ensures SQLite test DB artifacts are purged after the suite runs. // TestMain ensures SQLite test DB artifacts are purged after the suite runs.
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false) fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }
@@ -33,7 +38,7 @@ func TestClientRegistry_GetAndDelete(t *testing.T) {
} }
// Create node // Create node
n := &Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()} n := &Node{Node: cluster.Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
assert.NotEmpty(t, n.ClientID) assert.NotEmpty(t, n.ClientID)
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID)) assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
@@ -69,8 +74,8 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
a := &Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()} a := &Node{Node: cluster.Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()}}
b := &Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()} b := &Node{Node: cluster.Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(a)) assert.NoError(t, r.Put(a))
// Ensure distinct UpdatedAt values (DBs often have second precision) // Ensure distinct UpdatedAt values (DBs often have second precision)
time.Sleep(1100 * time.Millisecond) time.Sleep(1100 * time.Millisecond)
@@ -78,7 +83,7 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
// Update a to make it most recent // Update a to make it most recent
time.Sleep(1100 * time.Millisecond) time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(&Node{ClientID: a.ClientID, Name: a.Name})) assert.NoError(t, r.Put(&Node{Node: cluster.Node{ClientID: a.ClientID, Name: a.Name}}))
list, err := r.List() list, err := r.List()
assert.NoError(t, err) assert.NoError(t, err)
@@ -94,18 +99,21 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
func TestResponseBuilders_RedactionAndOpts(t *testing.T) { func TestResponseBuilders_RedactionAndOpts(t *testing.T) {
// Base node with all fields // Base node with all fields
n := Node{ n := Node{
ClientID: "cs5gfen1bgxz7s9i", Node: cluster.Node{
Name: "pp-node", ClientID: "cs5gfen1bgxz7s9i",
Role: "instance", Name: "pp-node",
SiteUrl: "https://photos.example.com", Role: "instance",
AdvertiseUrl: "http://node:2342", SiteUrl: "https://photos.example.com",
Labels: map[string]string{"env": "prod"}, AdvertiseUrl: "http://node:2342",
CreatedAt: time.Now().UTC().Format(time.RFC3339), Labels: map[string]string{"env": "prod"},
UpdatedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
},
} }
n.Database.Name = "dbn" dbInfo := n.ensureDatabase()
n.Database.User = "dbu" dbInfo.Name = "dbn"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339) dbInfo.User = "dbu"
dbInfo.RotatedAt = time.Now().UTC().Format(time.RFC3339)
// Non-admin (default opts): redact advertise/database // Non-admin (default opts): redact advertise/database
out := BuildClusterNode(n, NodeOpts{}) out := BuildClusterNode(n, NodeOpts{})
@@ -190,7 +198,7 @@ func TestClientRegistry_GetClusterNodeByUUID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
// Insert a node with NodeUUID // Insert a node with NodeUUID
nu := rnd.UUIDv7() nu := rnd.UUIDv7()
n := &Node{Name: "pp-getuuid", Role: "instance", UUID: nu} n := &Node{Node: cluster.Node{Name: "pp-getuuid", Role: "instance", UUID: nu}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
// Fetch DTO by NodeUUID // Fetch DTO by NodeUUID
@@ -208,7 +216,7 @@ func TestClientRegistry_FindByName_NormalizesDNSLabel(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
// Create canonical node name // Create canonical node name
n := &Node{Name: "my-node-prod", Role: "instance"} n := &Node{Node: cluster.Node{Name: "my-node-prod", Role: "instance"}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
// Lookup using mixed separators and case // Lookup using mixed separators and case
got, err := r.FindByName("My.Node/Prod") got, err := r.FindByName("My.Node/Prod")

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config" cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
) )
@@ -21,14 +22,14 @@ func TestClientRegistry_PutUpdateByUUID(t *testing.T) {
uuid := rnd.UUIDv7() uuid := rnd.UUIDv7()
// Create via UUID // Create via UUID
n := &Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}} n := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}}}
assert.NoError(t, r.Put(n)) assert.NoError(t, r.Put(n))
assert.NotEmpty(t, n.ClientID) assert.NotEmpty(t, n.ClientID)
assert.True(t, rnd.IsUUID(n.UUID)) assert.True(t, rnd.IsUUID(n.UUID))
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID)) assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
// Update same record by UUID only; change name and labels // Update same record by UUID only; change name and labels
upd := &Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}} upd := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}}}
assert.NoError(t, r.Put(upd)) assert.NoError(t, r.Put(upd))
got, err := r.FindByNodeUUID(uuid) got, err := r.FindByNodeUUID(uuid)
@@ -127,14 +128,14 @@ func TestClientRegistry_PutPrefersUUIDOverClientID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c) r, _ := NewClientRegistryWithConfig(c)
// Seed two separate records // Seed two separate records
n1 := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"} n1 := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
assert.NoError(t, r.Put(n1)) assert.NoError(t, r.Put(n1))
n2 := &Node{Name: "pp-b", Role: "service"} n2 := &Node{Node: cluster.Node{Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(n2)) assert.NoError(t, r.Put(n2))
// Now attempt to update by UUID of n1 while also passing n2.ClientID: // Now attempt to update by UUID of n1 while also passing n2.ClientID:
// implementation must use UUID and not attach to n2. // implementation must use UUID and not attach to n2.
upd := &Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"} upd := &Node{Node: cluster.Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"}}
assert.NoError(t, r.Put(upd)) assert.NoError(t, r.Put(upd))
got1, err := r.FindByNodeUUID(n1.UUID) got1, err := r.FindByNodeUUID(n1.UUID)

View File

@@ -23,28 +23,14 @@ func NodeOptsForSession(s *entity.Session) NodeOpts {
// BuildClusterNode builds a cluster.Node DTO from a registry.Node with redaction according to opts. // BuildClusterNode builds a cluster.Node DTO from a registry.Node with redaction according to opts.
func BuildClusterNode(n Node, opts NodeOpts) cluster.Node { func BuildClusterNode(n Node, opts NodeOpts) cluster.Node {
out := cluster.Node{ out := n.Node
UUID: n.UUID,
Name: n.Name, if !opts.IncludeAdvertiseUrl {
Role: n.Role, out.AdvertiseUrl = ""
ClientID: n.ClientID,
SiteUrl: n.SiteUrl,
Labels: n.Labels,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
} }
if opts.IncludeAdvertiseUrl && n.AdvertiseUrl != "" { if !opts.IncludeDatabase {
out.AdvertiseUrl = n.AdvertiseUrl out.Database = nil
}
if opts.IncludeDatabase {
out.Database = &cluster.NodeDatabase{
Name: n.Database.Name,
User: n.Database.User,
Driver: n.Database.Driver,
RotatedAt: n.Database.RotatedAt,
}
} }
return out return out

View File

@@ -0,0 +1,18 @@
package cluster
// RegisterRequest represents the JSON payload sent to the Portal when a node
// registers or refreshes its metadata.
//
// swagger:model RegisterRequest
type RegisterRequest struct {
NodeName string `json:"nodeName"`
NodeUUID string `json:"nodeUUID,omitempty"`
NodeRole string `json:"nodeRole,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
SiteUrl string `json:"siteUrl,omitempty"`
ClientID string `json:"clientId,omitempty"`
ClientSecret string `json:"clientSecret,omitempty"`
RotateDatabase bool `json:"rotateDatabase,omitempty"`
RotateSecret bool `json:"rotateSecret,omitempty"`
}

View File

@@ -68,6 +68,7 @@ type RegisterResponse struct {
Node Node `json:"node"` Node Node `json:"node"`
Database RegisterDatabase `json:"database"` Database RegisterDatabase `json:"database"`
Secrets *RegisterSecrets `json:"secrets,omitempty"` Secrets *RegisterSecrets `json:"secrets,omitempty"`
JWKSUrl string `json:"jwksUrl,omitempty"`
AlreadyRegistered bool `json:"alreadyRegistered"` AlreadyRegistered bool `json:"alreadyRegistered"`
AlreadyProvisioned bool `json:"alreadyProvisioned"` AlreadyProvisioned bool `json:"alreadyProvisioned"`
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
// Close database connection. // Close database connection.
_ = c.CloseDb() _ = c.CloseDb()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -23,7 +24,11 @@ func TestMain(m *testing.M) {
get.SetConfig(c) get.SetConfig(c)
photoprism.SetConfig(c) photoprism.SetConfig(c)
// Run unit tests.
code := m.Run() code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code) os.Exit(code)
} }

View File

@@ -17,6 +17,7 @@ const (
MethodSession MethodType = "session" MethodSession MethodType = "session"
MethodOAuth2 MethodType = "oauth2" MethodOAuth2 MethodType = "oauth2"
Method2FA MethodType = "2fa" Method2FA MethodType = "2fa"
MethodJWT MethodType = "jwt"
) )
// Method casts a string to a normalized method type. // Method casts a string to a normalized method type.
@@ -31,6 +32,8 @@ func Method(s string) MethodType {
return MethodOAuth2 return MethodOAuth2
case "2fa", "mfa", "otp", "totp": case "2fa", "mfa", "otp", "totp":
return Method2FA return Method2FA
case "jwt", "jwks":
return MethodJWT
case "access_token": case "access_token":
return MethodDefault return MethodDefault
default: default:
@@ -57,6 +60,8 @@ func (t MethodType) Pretty() string {
return "OAuth2" return "OAuth2"
case Method2FA: case Method2FA:
return "2FA" return "2FA"
case MethodJWT:
return "JWT"
default: default:
return txt.UpperFirst(t.String()) return txt.UpperFirst(t.String())
} }
@@ -110,3 +115,8 @@ func (t MethodType) IsDefault() bool {
func (t MethodType) IsSession() bool { func (t MethodType) IsSession() bool {
return t.String() == MethodSession.String() return t.String() == MethodSession.String()
} }
// IsJWT checks if this is the JSON Web Token (JWT) method.
func (t MethodType) IsJWT() bool {
return t.String() == MethodJWT.String()
}

View File

@@ -211,6 +211,21 @@ func TestAuthorization(t *testing.T) {
assert.Equal(t, AuthBearer, authType) assert.Equal(t, AuthBearer, authType)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken) assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
}) })
t.Run("JWTToken", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
Header: make(http.Header),
}
token := "eyJhbGciOiJFZERTQSIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJwb3J0YWw6dGVzdCIsImF1ZCI6Im5vZGU6YWJjIiwiZXhwIjoxNzAwMDAwMDB9.dGVzdC1zaWduYXR1cmUtYnl0ZXM"
c.Request.Header.Add(Auth, "Bearer "+token)
authType, authToken := Authorization(c)
assert.Equal(t, AuthBearer, authType)
assert.Equal(t, token, authToken)
})
} }
func TestBasicAuth(t *testing.T) { func TestBasicAuth(t *testing.T) {

View File

@@ -0,0 +1,5 @@
package header
// ETag is the response header containing an entity tag that allows
// clients and caches to perform conditional requests.
const ETag = "ETag"

View File

@@ -19,7 +19,7 @@ func ID(s string) string {
prev = r prev = r
switch r { switch r {
case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_': case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_', '.':
return r return r
} }