Skip to content

Commit efec0c9

Browse files
authored
feat: add jwt refresh interval variable (#1952)
1 parent 5078e31 commit efec0c9

4 files changed

Lines changed: 113 additions & 14 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ PGID=1000
2727
# IMPORTANT: Generate unique values for production!
2828
ENCRYPTION_KEY=your-32-char-encryption-key-here
2929
JWT_SECRET=your-super-secret-jwt-key-change-this
30+
# JWT refresh token expiry duration (default: 168h = 7 days). Accepts Go duration format (e.g., 72h, 720h, 8760h).
31+
# JWT_REFRESH_EXPIRY=168h
3032

3133
# Database Configuration
3234
DATABASE_URL=file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate

backend/internal/config/config.go

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,17 @@ const (
2727
// Fields with `options:"file"` support Docker secrets via the _FILE suffix.
2828
// Available options: file, toLower, trimTrailingSlash
2929
type Config struct {
30-
AppUrl string `env:"APP_URL" default:"http://localhost:3552"`
31-
DatabaseURL string `env:"DATABASE_URL" default:"file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" options:"file"`
32-
Port string `env:"PORT" default:"3552"`
33-
Listen string `env:"LISTEN" default:""`
34-
TLSEnabled bool `env:"TLS_ENABLED" default:"false"`
35-
TLSCertFile string `env:"TLS_CERT_FILE" default:""`
36-
TLSKeyFile string `env:"TLS_KEY_FILE" default:""`
37-
Environment AppEnvironment `env:"ENVIRONMENT" default:"production"`
38-
JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` //nolint:gosec // configuration field name is part of stable config API
39-
EncryptionKey string `env:"ENCRYPTION_KEY" default:"arcane-dev-key-32-characters!!!" options:"file"`
30+
AppUrl string `env:"APP_URL" default:"http://localhost:3552"`
31+
DatabaseURL string `env:"DATABASE_URL" default:"file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" options:"file"`
32+
Port string `env:"PORT" default:"3552"`
33+
Listen string `env:"LISTEN" default:""`
34+
TLSEnabled bool `env:"TLS_ENABLED" default:"false"`
35+
TLSCertFile string `env:"TLS_CERT_FILE" default:""`
36+
TLSKeyFile string `env:"TLS_KEY_FILE" default:""`
37+
Environment AppEnvironment `env:"ENVIRONMENT" default:"production"`
38+
JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` //nolint:gosec // configuration field name is part of stable config API
39+
JWTRefreshExpiry time.Duration `env:"JWT_REFRESH_EXPIRY" default:"168h"`
40+
EncryptionKey string `env:"ENCRYPTION_KEY" default:"arcane-dev-key-32-characters!!!" options:"file"`
4041

4142
OidcEnabled bool `env:"OIDC_ENABLED" default:"false"`
4243
OidcClientID string `env:"OIDC_CLIENT_ID" default:"" options:"file"`
@@ -123,7 +124,7 @@ func loadFromEnv(cfg *Config) {
123124
envValue = defaultValue
124125
}
125126

126-
setFieldValue(field, envValue)
127+
setFieldValueInternal(field, fieldType, envValue)
127128
})
128129
}
129130

@@ -241,8 +242,8 @@ func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructFi
241242
}
242243
}
243244

244-
// setFieldValue sets a reflect.Value from a string based on the field's type.
245-
func setFieldValue(field reflect.Value, value string) {
245+
// setFieldValueInternal sets a reflect.Value from a string based on the field's type.
246+
func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, value string) {
246247
if !field.CanSet() {
247248
return
248249
}
@@ -274,6 +275,37 @@ func setFieldValue(field reflect.Value, value string) {
274275
return
275276
}
276277

278+
if field.Type() == reflect.TypeFor[time.Duration]() {
279+
applyDurationDefault := func(reason string) {
280+
envTag := fieldType.Tag.Get("env")
281+
defaultValue := fieldType.Tag.Get("default")
282+
283+
if fallback, fallbackErr := time.ParseDuration(defaultValue); fallbackErr == nil {
284+
slog.Warn(reason+", using tagged default",
285+
"field", envTag,
286+
"value", value,
287+
"default", defaultValue)
288+
field.SetInt(int64(fallback))
289+
} else {
290+
slog.Warn(reason+", and invalid tagged default",
291+
"field", envTag,
292+
"value", value,
293+
"default", defaultValue)
294+
}
295+
}
296+
297+
if d, err := time.ParseDuration(value); err == nil {
298+
if d > 0 {
299+
field.SetInt(int64(d))
300+
} else {
301+
applyDurationDefault("Non-positive duration for config field")
302+
}
303+
} else {
304+
applyDurationDefault("Invalid duration for config field")
305+
}
306+
return
307+
}
308+
277309
// Handle custom types based on underlying kind
278310
if field.Type().ConvertibleTo(reflect.TypeFor[string]()) {
279311
// String-based types like AppEnvironment

backend/internal/config/config_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package config
33
import (
44
"os"
55
"path/filepath"
6+
"reflect"
67
"testing"
8+
"time"
79

810
"github.com/getarcaneapp/arcane/backend/internal/common"
911
"github.com/stretchr/testify/assert"
@@ -353,6 +355,69 @@ func TestConfig_GetManagerBaseURL(t *testing.T) {
353355
})
354356
}
355357

358+
func TestConfig_JWTRefreshExpiry(t *testing.T) {
359+
origJWTRefreshExpiry := os.Getenv("JWT_REFRESH_EXPIRY")
360+
defer restoreEnv("JWT_REFRESH_EXPIRY", origJWTRefreshExpiry)
361+
362+
t.Run("defaults to 7 days when not set", func(t *testing.T) {
363+
unsetEnv(t, "JWT_REFRESH_EXPIRY")
364+
365+
cfg := Load()
366+
assert.Equal(t, 7*24*time.Hour, cfg.JWTRefreshExpiry)
367+
})
368+
369+
t.Run("parses custom duration from env var", func(t *testing.T) {
370+
setEnv(t, "JWT_REFRESH_EXPIRY", "48h")
371+
372+
cfg := Load()
373+
assert.Equal(t, 48*time.Hour, cfg.JWTRefreshExpiry)
374+
})
375+
376+
t.Run("supports compound duration format", func(t *testing.T) {
377+
setEnv(t, "JWT_REFRESH_EXPIRY", "24h30m")
378+
379+
cfg := Load()
380+
assert.Equal(t, 24*time.Hour+30*time.Minute, cfg.JWTRefreshExpiry)
381+
})
382+
383+
t.Run("falls back to default on invalid duration", func(t *testing.T) {
384+
setEnv(t, "JWT_REFRESH_EXPIRY", "notaduration")
385+
386+
cfg := Load()
387+
assert.Equal(t, 168*time.Hour, cfg.JWTRefreshExpiry)
388+
})
389+
390+
t.Run("falls back to default on zero duration", func(t *testing.T) {
391+
setEnv(t, "JWT_REFRESH_EXPIRY", "0s")
392+
393+
cfg := Load()
394+
assert.Equal(t, 168*time.Hour, cfg.JWTRefreshExpiry)
395+
})
396+
397+
t.Run("falls back to default on negative duration", func(t *testing.T) {
398+
setEnv(t, "JWT_REFRESH_EXPIRY", "-1h")
399+
400+
cfg := Load()
401+
assert.Equal(t, 168*time.Hour, cfg.JWTRefreshExpiry)
402+
})
403+
}
404+
405+
func TestSetFieldValueInternal_DurationUsesFieldTagDefault(t *testing.T) {
406+
type durationConfig struct {
407+
SessionTimeout time.Duration `env:"SESSION_TIMEOUT" default:"2h"`
408+
}
409+
410+
cfg := &durationConfig{}
411+
v := reflect.ValueOf(cfg).Elem()
412+
field := v.FieldByName("SessionTimeout")
413+
fieldType, ok := v.Type().FieldByName("SessionTimeout")
414+
require.True(t, ok)
415+
416+
setFieldValueInternal(field, fieldType, "invalid-duration")
417+
418+
assert.Equal(t, 2*time.Hour, cfg.SessionTimeout)
419+
}
420+
356421
func restoreEnv(key, value string) {
357422
if value == "" {
358423
_ = os.Unsetenv(key)

backend/internal/services/auth_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func NewAuthService(userService *UserService, settingsService *SettingsService,
6464
settingsService: settingsService,
6565
eventService: eventService,
6666
jwtSecret: crypto.CheckOrGenerateJwtSecret(jwtSecret),
67-
refreshExpiry: 7 * 24 * time.Hour,
67+
refreshExpiry: cfg.JWTRefreshExpiry,
6868
config: cfg,
6969
}
7070
}

0 commit comments

Comments
 (0)