Skip to content

Commit 8d15207

Browse files
committed
Auth: Rotate portal JWT signing keys on a schedule and on demand
1 parent 1b11555 commit 8d15207

9 files changed

Lines changed: 322 additions & 0 deletions

File tree

internal/commands/auth_jwt_keys.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"github.com/urfave/cli/v2"
1010

1111
"github.com/photoprism/photoprism/internal/config"
12+
"github.com/photoprism/photoprism/internal/event"
1213
"github.com/photoprism/photoprism/internal/photoprism/get"
14+
"github.com/photoprism/photoprism/pkg/log/status"
1315
)
1416

1517
// AuthJWTKeysCommand groups JWT key management helpers.
@@ -18,9 +20,83 @@ var AuthJWTKeysCommand = &cli.Command{
1820
Usage: "JWT signing key helpers",
1921
Subcommands: []*cli.Command{
2022
AuthJWTKeysListCommand,
23+
AuthJWTKeysRotateCommand,
2124
},
2225
}
2326

27+
// AuthJWTKeysRotateCommand replaces the active JWT signing key.
28+
var AuthJWTKeysRotateCommand = &cli.Command{
29+
Name: "rotate",
30+
Usage: "Replaces the active JWT signing key",
31+
ArgsUsage: "",
32+
Flags: []cli.Flag{
33+
JsonFlag(),
34+
},
35+
Action: authJWTKeysRotateAction,
36+
}
37+
38+
// authJWTKeysRotateAction issues a new portal signing key and reports both key IDs.
39+
// The replaced key stays in the JWKS for jwt.RotationOverlap.
40+
func authJWTKeysRotateAction(ctx *cli.Context) error {
41+
return CallWithDependencies(ctx, func(conf *config.Config) error {
42+
if err := requirePortal(conf); err != nil {
43+
return err
44+
}
45+
46+
manager := get.JWTManager()
47+
if manager == nil {
48+
return cli.Exit(errors.New("jwt manager not available"), 1)
49+
}
50+
51+
prev, _ := manager.ActiveKey()
52+
53+
// A new key signs as soon as it exists, so an error alongside one means only the
54+
// retirement is outstanding. A plain failure would invite a key-minting retry.
55+
key, err := manager.RotateKey()
56+
if err != nil && key == nil {
57+
return cli.Exit(err, 1)
58+
} else if err != nil {
59+
fmt.Printf("Rotated to %s, but retiring the previous key did not complete: %s\n", key.Kid, err)
60+
}
61+
62+
event.AuditInfo([]string{"cli", "jwt", "rotate signing key", status.Succeeded})
63+
64+
prevKid := ""
65+
retired := ""
66+
if prev != nil && prev.Kid != key.Kid {
67+
prevKid = prev.Kid
68+
for _, k := range manager.AllKeys() {
69+
if k.Kid == prevKid && k.NotAfter > 0 {
70+
retired = time.Unix(k.NotAfter, 0).UTC().Format(time.RFC3339)
71+
}
72+
}
73+
}
74+
75+
if ctx.Bool("json") {
76+
return printJSON(map[string]any{
77+
"kid": key.Kid,
78+
"replaced": prevKid,
79+
"notAfter": retired,
80+
})
81+
}
82+
83+
fmt.Println()
84+
fmt.Printf("New signing key: %s\n", key.Kid)
85+
switch {
86+
case prevKid == "":
87+
fmt.Println("No previous key to replace.")
88+
case retired == "":
89+
fmt.Printf("Replaced key %s.\n", prevKid)
90+
default:
91+
fmt.Printf("Replaced key %s, which verifies until %s.\n", prevKid, retired)
92+
}
93+
fmt.Println("A running portal loads its keys at startup, so restart it to use the new key.")
94+
fmt.Println()
95+
96+
return nil
97+
})
98+
}
99+
24100
// AuthJWTKeysListCommand lists JWT signing keys.
25101
var AuthJWTKeysListCommand = &cli.Command{
26102
Name: "ls",

internal/config/config_cluster.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,17 @@ func (c *Config) JWTLeeway() int {
754754
return c.options.JWTLeeway
755755
}
756756

757+
// JWTRotateDays returns the portal signing key lifetime in days, 0 when scheduled rotation
758+
// is off. Use a negative value to disable it everywhere: ApplyCliContext reads a zero from
759+
// defaults.yml as unset, so 0 only disables from a flag, an env var, or options.yml.
760+
func (c *Config) JWTRotateDays() int {
761+
if c.options.JWTRotateDays < 0 {
762+
return 0
763+
}
764+
765+
return c.options.JWTRotateDays
766+
}
767+
757768
// JWTAllowedScopes returns an optional allow-list of accepted JWT scopes.
758769
func (c *Config) JWTAllowedScopes() list.Attr {
759770
if s := strings.TrimSpace(c.options.JWTScope); s != "" {

internal/config/config_cluster_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,3 +992,30 @@ func TestConfig_ClusterUUID_GenerateAndPersist(t *testing.T) {
992992

993993
c.options.OptionsYaml = optionsOriginal
994994
}
995+
996+
func TestConfig_JWTRotateDays(t *testing.T) {
997+
c := NewConfig(CliTestContext())
998+
original := c.options.JWTRotateDays
999+
1000+
t.Cleanup(func() {
1001+
c.options.JWTRotateDays = original
1002+
})
1003+
1004+
t.Run("Default", func(t *testing.T) {
1005+
c.options.JWTRotateDays = 90
1006+
assert.Equal(t, 90, c.JWTRotateDays())
1007+
})
1008+
t.Run("Custom", func(t *testing.T) {
1009+
c.options.JWTRotateDays = 30
1010+
assert.Equal(t, 30, c.JWTRotateDays())
1011+
})
1012+
t.Run("Disabled", func(t *testing.T) {
1013+
// 0 means the operator asked for manual rotation, so it must not read as unset.
1014+
c.options.JWTRotateDays = 0
1015+
assert.Equal(t, 0, c.JWTRotateDays())
1016+
})
1017+
t.Run("Negative", func(t *testing.T) {
1018+
c.options.JWTRotateDays = -7
1019+
assert.Equal(t, 0, c.JWTRotateDays())
1020+
})
1021+
}

internal/config/flags.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,12 @@ var Flags = CliFlags{
844844
Value: 60,
845845
EnvVars: EnvVars("JWT_LEEWAY"),
846846
}}, {
847+
Flag: &cli.IntFlag{
848+
Name: "jwt-rotate-days",
849+
Usage: "portal JWT signing key lifetime in `DAYS`, -1 to rotate manually only",
850+
Value: 90,
851+
EnvVars: EnvVars("JWT_ROTATE_DAYS"),
852+
}}, {
847853
Flag: &cli.StringFlag{
848854
Name: "portal-oidc-issuer",
849855
Usage: "Portal OIDC OP issuer `URL` advertised in discovery and ID tokens (defaults to site-url)",

internal/config/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ type Options struct {
176176
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
177177
JWTScope string `yaml:"JWTScope" json:"-" flag:"jwt-scope"`
178178
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
179+
JWTRotateDays int `yaml:"JWTRotateDays" json:"-" flag:"jwt-rotate-days"`
179180
PortalOIDCIssuer string `yaml:"PortalOIDCIssuer" json:"-" flag:"portal-oidc-issuer"`
180181
PortalOIDCTTL int `yaml:"PortalOIDCTTL" json:"-" flag:"portal-oidc-ttl"`
181182
PortalOIDCCodeTTL int `yaml:"PortalOIDCCodeTTL" json:"-" flag:"portal-oidc-code-ttl"`

internal/config/report.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
226226
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
227227
{"jwt-scope", c.JWTAllowedScopes().String()},
228228
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
229+
{"jwt-rotate-days", fmt.Sprintf("%d", c.JWTRotateDays())},
229230
{"advertise-url", clean.UriRedacted(c.AdvertiseUrl())},
230231

231232
// Networking.

internal/workers/jwt_keys.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package workers
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
iofs "io/fs"
7+
"time"
8+
9+
"github.com/photoprism/photoprism/internal/auth/jwt"
10+
"github.com/photoprism/photoprism/internal/config"
11+
"github.com/photoprism/photoprism/internal/event"
12+
"github.com/photoprism/photoprism/internal/photoprism/get"
13+
"github.com/photoprism/photoprism/pkg/clean"
14+
)
15+
16+
// JWTKeySchedule is the cron expression for the portal signing key check.
17+
// Key lifetimes are measured in days, so a daily check is close enough.
18+
const JWTKeySchedule = "0 4 * * *"
19+
20+
// jwtManager resolves the portal signing key manager, replaced in tests.
21+
var jwtManager = get.JWTManager
22+
23+
// RunJWTKeyRotation replaces the portal JWT signing key once it reaches the configured
24+
// lifetime. The due-date check reads memory only, so a run that is not due touches no disk.
25+
func RunJWTKeyRotation(conf *config.Config) {
26+
if conf == nil || !conf.Portal() {
27+
return
28+
}
29+
30+
days := conf.JWTRotateDays()
31+
32+
if days <= 0 {
33+
return
34+
}
35+
36+
rotateJWTKeys(jwtManager(), days)
37+
}
38+
39+
// rotateJWTKeys rotates the signing key held by manager once it reaches days, and
40+
// otherwise reapplies a retirement that an earlier run did not persist.
41+
func rotateJWTKeys(manager *jwt.Manager, days int) {
42+
if manager == nil {
43+
return
44+
}
45+
46+
if !manager.NeedsRotation(time.Duration(days) * 24 * time.Hour) {
47+
// Pick up a key whose retirement did not reach disk on an earlier run.
48+
if n, err := manager.RetireSuperseded(); err != nil {
49+
event.SystemError([]string{"jwt", "retire superseded signing key", "%s"}, keyErrorText(err))
50+
} else if n > 0 {
51+
event.SystemInfo([]string{"jwt", "retired %d superseded signing keys"}, n)
52+
}
53+
54+
return
55+
}
56+
57+
key, err := manager.RotateKey()
58+
59+
// A new key signs as soon as it exists, so an error after that point means the
60+
// replacement succeeded and only the retirement is outstanding.
61+
switch {
62+
case err != nil && key == nil:
63+
event.SystemError([]string{"jwt", "rotate signing key", "%s"}, keyErrorText(err))
64+
case err != nil:
65+
event.SystemWarn([]string{"jwt", "rotated signing key, retirement pending", "%s"}, keyErrorText(err))
66+
default:
67+
event.SystemInfo([]string{"jwt", "rotated signing key after %d days", "new key id %s"}, days, clean.Log(key.Kid))
68+
}
69+
}
70+
71+
// keyErrorText renders a key I/O error without the file path it names, since the system
72+
// channel is delivered to the web UI.
73+
func keyErrorText(err error) string {
74+
var pathErr *iofs.PathError
75+
76+
if errors.As(err, &pathErr) {
77+
return clean.Error(fmt.Errorf("%s: %w", pathErr.Op, pathErr.Err))
78+
}
79+
80+
return clean.Error(err)
81+
}

internal/workers/jwt_keys_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package workers
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/go-co-op/gocron/v2"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/photoprism/photoprism/internal/auth/jwt"
14+
"github.com/photoprism/photoprism/internal/config"
15+
)
16+
17+
// newJWTTestManager returns a key manager backed by a temp config directory.
18+
func newJWTTestManager(t *testing.T) *jwt.Manager {
19+
t.Helper()
20+
21+
c := config.NewMinimalTestConfig(t.TempDir())
22+
23+
m, err := jwt.NewManager(c)
24+
require.NoError(t, err)
25+
26+
t.Cleanup(func() {
27+
_ = os.RemoveAll(filepath.Join(c.PortalConfigPath(), "keys"))
28+
})
29+
30+
return m
31+
}
32+
33+
func TestRunJWTKeyRotation(t *testing.T) {
34+
// The portal gate is what RunJWTKeyRotation adds over rotateJWTKeys, so it is what
35+
// these cases cover; the rotation itself is exercised through rotateJWTKeys below.
36+
t.Run("NilConfig", func(t *testing.T) {
37+
assert.NotPanics(t, func() { RunJWTKeyRotation(nil) })
38+
})
39+
t.Run("NotAPortal", func(t *testing.T) {
40+
c := config.NewMinimalTestConfig(t.TempDir())
41+
require.False(t, c.Portal())
42+
43+
// Instances do not issue JWTs, so the manager must not even be resolved.
44+
resolved := false
45+
original := jwtManager
46+
jwtManager = func() *jwt.Manager { resolved = true; return nil }
47+
t.Cleanup(func() { jwtManager = original })
48+
49+
RunJWTKeyRotation(c)
50+
assert.False(t, resolved)
51+
})
52+
}
53+
54+
func TestRotateJWTKeys(t *testing.T) {
55+
t.Run("NoManager", func(t *testing.T) {
56+
assert.NotPanics(t, func() { rotateJWTKeys(nil, 90) })
57+
})
58+
t.Run("NotDue", func(t *testing.T) {
59+
m := newJWTTestManager(t)
60+
before, err := m.EnsureActiveKey()
61+
require.NoError(t, err)
62+
63+
rotateJWTKeys(m, 90)
64+
65+
after, err := m.ActiveKey()
66+
require.NoError(t, err)
67+
assert.Equal(t, before.Kid, after.Kid)
68+
assert.Len(t, m.JWKS().Keys, 1)
69+
})
70+
t.Run("Disabled", func(t *testing.T) {
71+
m := newJWTTestManager(t)
72+
before, err := m.EnsureActiveKey()
73+
require.NoError(t, err)
74+
75+
m.SetNow(func() time.Time { return time.Now().UTC().Add(10 * 365 * 24 * time.Hour) })
76+
rotateJWTKeys(m, 0)
77+
78+
after, err := m.ActiveKey()
79+
require.NoError(t, err)
80+
assert.Equal(t, before.Kid, after.Kid, "a disabled lifetime must not rotate, however old the key is")
81+
})
82+
t.Run("Due", func(t *testing.T) {
83+
m := newJWTTestManager(t)
84+
before, err := m.EnsureActiveKey()
85+
require.NoError(t, err)
86+
87+
// Move past the configured lifetime so the run is due.
88+
m.SetNow(func() time.Time { return time.Now().UTC().Add(91 * 24 * time.Hour) })
89+
rotateJWTKeys(m, 90)
90+
91+
after, err := m.ActiveKey()
92+
require.NoError(t, err)
93+
assert.NotEqual(t, before.Kid, after.Kid)
94+
assert.EqualValues(t, 0, after.NotAfter)
95+
// The replaced key keeps verifying during the overlap.
96+
assert.Len(t, m.JWKS().Keys, 2)
97+
})
98+
}
99+
100+
func TestJWTKeySchedule(t *testing.T) {
101+
// NewJob feeds this to gocron, which rejects a malformed expression at registration.
102+
scheduler, err := gocron.NewScheduler()
103+
require.NoError(t, err)
104+
t.Cleanup(func() { _ = scheduler.Shutdown() })
105+
106+
_, err = scheduler.NewJob(gocron.CronJob(JWTKeySchedule, false), gocron.NewTask(func() {}))
107+
assert.NoError(t, err)
108+
}

internal/workers/workers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ func Start(conf *config.Config) {
5656
log.Errorf("scheduler: %s (backup)", err)
5757
}
5858

59+
// Only portals issue JWTs, so only they need a signing key to rotate. The startup
60+
// check runs as well, since a portal that is never up at the scheduled hour would
61+
// otherwise never reach a tick.
62+
if conf.Portal() {
63+
if err = NewJob("jwt-keys", JWTKeySchedule, func() { RunJWTKeyRotation(conf) }); err != nil {
64+
log.Errorf("scheduler: %s (jwt keys)", err)
65+
}
66+
67+
go event.Safe(func() { RunJWTKeyRotation(conf) })
68+
}
69+
5970
// Only schedule index and vision jobs if this is not a portal.
6071
if !conf.Portal() {
6172
// Schedule indexing job.

0 commit comments

Comments
 (0)