From ee357153f2e520291aa4a3b31ccd89d86ccb2964 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:16:42 +0000 Subject: [PATCH] feat: support JWT bootstrap and CLI access to a standalone Francis runtime Pocket ID can now authenticate to a standalone runtime with a JWT as well as a pre-shared key: FRANCIS_HOST_JWT carries the token inline, while FRANCIS_HOST_JWT_FILE keeps a path that Francis re-reads on every connection, so a rotated token (a Kubernetes projected service account token, say) is picked up without a restart. Exactly one bootstrap method must be configured, which the config validates up front. The one-time-access-token command works against a standalone runtime again. It has no actor host of its own, so it joins the cluster as a client-only host for just long enough to write the token: that hosts no actor, so the runtime never places one on it, and it disconnects before the command returns. --- backend/go.mod | 2 +- backend/go.sum | 4 +- .../internal/bootstrap/actors_bootstrap.go | 52 +++++++++---- .../bootstrap/actors_bootstrap_test.go | 46 +++++++++++ .../bootstrap/actors_client_bootstrap.go | 78 +++++++++++++++++++ .../internal/cmds/one_time_access_token.go | 66 +++++++++++----- backend/internal/common/env_config.go | 61 ++++++++++++--- backend/internal/common/env_config_test.go | 52 ++++++++++++- 8 files changed, 314 insertions(+), 47 deletions(-) create mode 100644 backend/internal/bootstrap/actors_client_bootstrap.go diff --git a/backend/go.mod b/backend/go.mod index 13a68904..c8de3990 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd + github.com/italypaleale/francis v0.1.0-beta.23.0.20260817170946-54d0bec147d3 github.com/italypaleale/go-kit v1.0.0 github.com/italypaleale/go-sql-utils v0.3.5 github.com/jackc/pgx/v5 v5.10.0 diff --git a/backend/go.sum b/backend/go.sum index 9fe128f0..ece0f5ae 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -247,8 +247,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd h1:GVUawGFf4RwwXIUPX+TKgBqi60lTHCpP6n5roIthPZU= -github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= +github.com/italypaleale/francis v0.1.0-beta.23.0.20260817170946-54d0bec147d3 h1:EEcS31NBZ0fOsvrjE/P4zbSu8TyQCIDSUM4z3U5JILc= +github.com/italypaleale/francis v0.1.0-beta.23.0.20260817170946-54d0bec147d3/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= github.com/italypaleale/go-kit v1.0.0 h1:c+SaYHTaoZzTgbPXhSbNVdNRkNdNhE0YljBWWoPTdKI= github.com/italypaleale/go-kit v1.0.0/go.mod h1:wg4UsIbsbtDiVqUjJdo/tO9lXo0OXBuwPOpSJ+u+1jI= github.com/italypaleale/go-sql-utils v0.3.5 h1:kkrhIo1tVJvcsjRc1kePWE9Cqdm26+Et5XZYSNmv764= diff --git a/backend/internal/bootstrap/actors_bootstrap.go b/backend/internal/bootstrap/actors_bootstrap.go index 1d8d563a..4dc8437f 100644 --- a/backend/internal/bootstrap/actors_bootstrap.go +++ b/backend/internal/bootstrap/actors_bootstrap.go @@ -30,6 +30,10 @@ import ( // That runtime owns the actor data instead, so it can only be reached through the runtime itself var ErrRemoteFrancisRuntime = errors.New("the actor data is owned by the standalone Francis runtime configured in FRANCIS_HOST, and is not stored in Pocket ID's database") +// ErrEmbeddedFrancisRuntime is returned by the helpers that reach the actor data through a standalone Francis runtime when Pocket ID runs an embedded one +// There is no runtime to connect to in that case, and the actor data is in Pocket ID's own database +var ErrEmbeddedFrancisRuntime = errors.New("the actor runtime is embedded in Pocket ID, so there is no standalone Francis runtime to connect to") + type NewActorsOpts struct { Postgres *pgxpool.Pool @@ -138,22 +142,12 @@ func (o *NewActorsOpts) newEmbeddedHost(log *slog.Logger) (*local.Host, error) { // The runtime owns the actor state, placement, and alarms, so none of the embedded runtime's database and clustering options apply here // That includes the cap on the number of hosts in the cluster, which the runtime enforces through its own "maxHosts" setting: Pocket ID cannot limit itself to a single replica from this side func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) { - opts := []remote.HostOption{ + opts := append( + remoteConnectionOptions(o.EnvConfig, log), // Actors placed on this host are invoked by its peers at this address, which is also the one it advertises to the runtime remote.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)), - remote.WithLogger(log), - remote.WithRuntimeAddresses(o.EnvConfig.FrancisAddresses...), - remote.WithHostBootstrapPSK(o.EnvConfig.FrancisHostPSK), - remote.WithShutdownGracePeriod(10 * time.Second), - } - - // Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection - // Francis requires the trust decision to be explicit, so without a pinned CA we have to opt into trusting the certificate served on first use, which it warns about - if len(o.EnvConfig.FrancisCA) > 0 { - opts = append(opts, remote.WithPinnedCA(o.EnvConfig.FrancisCA)) - } else { - opts = append(opts, remote.WithUnsafeNoPinnedCA()) - } + remote.WithShutdownGracePeriod(10*time.Second), + ) h, err := remote.NewHost(opts...) if err != nil { @@ -163,6 +157,36 @@ func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) { return h, nil } +// remoteConnectionOptions builds the options that address and authenticate Pocket ID to a standalone Francis runtime +// Both the actor host and the short-lived client the CLI commands use go through here, so they always present the same identity to the same cluster +func remoteConnectionOptions(envConfig *common.EnvConfigSchema, log *slog.Logger) []remote.HostOption { + opts := []remote.HostOption{ + remote.WithLogger(log), + remote.WithRuntimeAddresses(envConfig.FrancisAddresses...), + } + + // The configuration is validated to carry exactly one bootstrap method, so the first match is the one the operator chose + switch { + case len(envConfig.FrancisHostPSK) > 0: + opts = append(opts, remote.WithHostBootstrapPSK(envConfig.FrancisHostPSK)) + case envConfig.FrancisHostJWTFile != "": + // Francis re-reads the file on every connection, so a rotated token is picked up without restarting Pocket ID + opts = append(opts, remote.WithHostBootstrapJWTFile(envConfig.FrancisHostJWTFile)) + case envConfig.FrancisHostJWT != "": + opts = append(opts, remote.WithHostBootstrapJWT(envConfig.FrancisHostJWT)) + } + + // Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection + // Francis requires the trust decision to be explicit, so without a pinned CA we have to opt into trusting the certificate served on first use, which it warns about + if len(envConfig.FrancisCA) > 0 { + opts = append(opts, remote.WithPinnedCA(envConfig.FrancisCA)) + } else { + opts = append(opts, remote.WithUnsafeNoPinnedCA()) + } + + return opts +} + // Derive a PSK from the global encryption key func (o *NewActorsOpts) getPSK() ([]byte, error) { // This is tied to the instance ID of the Pocket ID deployment/cluster diff --git a/backend/internal/bootstrap/actors_bootstrap_test.go b/backend/internal/bootstrap/actors_bootstrap_test.go index b3db830c..9a1b7fc1 100644 --- a/backend/internal/bootstrap/actors_bootstrap_test.go +++ b/backend/internal/bootstrap/actors_bootstrap_test.go @@ -2,10 +2,14 @@ package bootstrap import ( "bytes" + "context" "encoding/hex" + "log/slog" + "os" "path/filepath" "testing" + francishost "github.com/italypaleale/francis/host" "github.com/italypaleale/francis/host/local" "github.com/italypaleale/francis/host/remote" "github.com/libtnb/sqlite" @@ -98,6 +102,48 @@ func TestNewActorsSelectsTopology(t *testing.T) { require.NotEmpty(t, rateLimitServices) }) + // Each bootstrap method has to produce a host Francis accepts, which is the only part of the remote wiring that can be checked without a runtime to connect to + t.Run("every bootstrap method builds a valid remote host", func(t *testing.T) { + jwtFile := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600)) + + for name, apply := range map[string]func(cfg *common.EnvConfigSchema){ + "PSK": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") }, + "JWT": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostJWT = "header.payload.signature" }, + "JWT file": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostJWTFile = jwtFile }, + } { + t.Run(name, func(t *testing.T) { + cfg := baseConfig(t) + cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"} + apply(cfg) + + opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"} + h, err := opts.newRemoteHost(slog.New(slog.DiscardHandler)) + require.NoError(t, err) + require.NotNil(t, h) + }) + } + }) + + t.Run("no bootstrap method is rejected by Francis", func(t *testing.T) { + cfg := baseConfig(t) + cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"} + + opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"} + _, err := opts.newRemoteHost(slog.New(slog.DiscardHandler)) + require.Error(t, err) + }) + + t.Run("the actor client requires a standalone runtime", func(t *testing.T) { + cfg := baseConfig(t) + + err := WithActorClient(t.Context(), cfg, func(context.Context, francishost.Host) error { + t.Fatal("the callback must not run without a standalone runtime") + return nil + }) + require.ErrorIs(t, err, ErrEmbeddedFrancisRuntime) + }) + t.Run("state store is unavailable with a remote runtime", func(t *testing.T) { cfg := baseConfig(t) cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"} diff --git a/backend/internal/bootstrap/actors_client_bootstrap.go b/backend/internal/bootstrap/actors_client_bootstrap.go new file mode 100644 index 00000000..c31ecaeb --- /dev/null +++ b/backend/internal/bootstrap/actors_client_bootstrap.go @@ -0,0 +1,78 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + francishost "github.com/italypaleale/francis/host" + "github.com/italypaleale/francis/host/remote" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +// actorClientConnectTimeout bounds how long a CLI command waits to join the cluster +// Francis reconnects to the runtime indefinitely, which is right for the server but would leave a command hanging against an unreachable runtime +const actorClientConnectTimeout = 30 * time.Second + +// WithActorClient connects to the standalone Francis runtime, calls fn once the connection is live, and disconnects before returning. +// It's meant for CLI commands, which have no actor host of their own: the client joins the cluster only for the duration of fn, and hosts no actor while connected, so the runtime never places an actor on it. +// It requires FRANCIS_HOST to point to a standalone runtime, and returns ErrEmbeddedFrancisRuntime otherwise, since an embedded runtime is reached through the database instead. +func WithActorClient(parentCtx context.Context, envConfig *common.EnvConfigSchema, fn func(ctx context.Context, client francishost.Host) error) error { + if envConfig.HasEmbeddedFrancisRuntime() { + return ErrEmbeddedFrancisRuntime + } + + log := slog.Default().With("scope", "actor-client") + + // The client hosts no actor, so it advertises no address of its own and binds nothing + // The short grace period keeps a command from lingering on the way out, since there are no actors to drain + client, err := remote.NewHost(append( + remoteConnectionOptions(envConfig, log), + remote.WithClientOnly(), + remote.WithShutdownGracePeriod(2*time.Second), + )...) + if err != nil { + return fmt.Errorf("failed to create the actor client: %w", err) + } + + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- client.Run(ctx) + }() + + // Every operation travels on the runtime session, so nothing can run before the client has joined the cluster + connectCtx, connectCancel := context.WithTimeout(ctx, actorClientConnectTimeout) + defer connectCancel() + + select { + case <-client.Ready(): + case runErr := <-runErrCh: + return fmt.Errorf("failed to connect to the Francis runtime: %w", runErr) + case <-connectCtx.Done(): + cancel() + <-runErrCh + return fmt.Errorf("timed out connecting to the Francis runtime after %v", actorClientConnectTimeout) + } + + fnErr := fn(ctx, client) + + // Leave the cluster before returning, so the runtime drops the registration instead of waiting for the health check to lapse + cancel() + runErr := <-runErrCh + + // The error from fn is the one the caller asked for, and a canceled run is just the disconnect we asked for + switch { + case fnErr != nil: + return fnErr + case runErr != nil && !errors.Is(runErr, context.Canceled): + return fmt.Errorf("error disconnecting from the Francis runtime: %w", runErr) + default: + return nil + } +} diff --git a/backend/internal/cmds/one_time_access_token.go b/backend/internal/cmds/one_time_access_token.go index 9224b0ed..257afe90 100644 --- a/backend/internal/cmds/one_time_access_token.go +++ b/backend/internal/cmds/one_time_access_token.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + francishost "github.com/italypaleale/francis/host" + "github.com/jackc/pgx/v5/pgxpool" "github.com/spf13/cobra" "gorm.io/gorm" @@ -53,25 +55,9 @@ var oneTimeAccessTokenCmd = &cobra.Command{ return err } - // One-time access tokens are stored in the actor state store - // The CLI doesn't run the full actor host, so it uses a minimal state store to persist the token directly - actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{ - DB: db, - Postgres: pg, - EnvConfig: &common.EnvConfig, - InstanceID: instanceID, - }) - if errors.Is(err, bootstrap.ErrRemoteFrancisRuntime) { - // Writing the token through a standalone runtime would mean joining the cluster as a full actor host, which this short-lived command does not do - return errors.New("generating a one-time access token from the CLI is not supported when FRANCIS_HOST points to a standalone Francis runtime: request the token from a running Pocket ID instance instead") - } else if err != nil { - return fmt.Errorf("failed to initialize the actor state store: %w", err) - } - - // Create a new access token that expires in 1 hour - tokenCtx, tokenCancel := context.WithTimeout(cmd.Context(), 10*time.Second) - defer tokenCancel() - token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, user.ID, time.Hour, false) + // One-time access tokens live in the actor state store, which is reached differently depending on where the actor runtime runs + // The CLI never runs the full actor host: with an embedded runtime it writes to Pocket ID's database directly, and with a standalone one it joins the cluster as a client for just long enough to write the token + token, err := storeOneTimeAccessToken(cmd.Context(), db, pg, instanceID, user.ID) if err != nil { return fmt.Errorf("failed to create access token: %w", err) } @@ -84,6 +70,48 @@ var oneTimeAccessTokenCmd = &cobra.Command{ }, } +// storeOneTimeAccessToken persists a one-time access token valid for one hour, through whichever actor runtime this deployment uses, and returns the token +func storeOneTimeAccessToken(ctx context.Context, db *gorm.DB, pg *pgxpool.Pool, instanceID string, userID string) (string, error) { + // A standalone Francis runtime owns the actor state, so the token is written through a short-lived client connection to it + if !common.EnvConfig.HasEmbeddedFrancisRuntime() { + var token string + err := bootstrap.WithActorClient(ctx, &common.EnvConfig, func(clientCtx context.Context, client francishost.Host) error { + tokenCtx, tokenCancel := context.WithTimeout(clientCtx, 10*time.Second) + defer tokenCancel() + + var storeErr error + token, _, storeErr = onetimeaccess.StoreToken(tokenCtx, client, userID, time.Hour, false) + return storeErr + }) + if err != nil { + return "", err + } + + return token, nil + } + + // With the embedded runtime the actor state lives in Pocket ID's own database, which a minimal state store writes to without running an actor host + actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{ + DB: db, + Postgres: pg, + EnvConfig: &common.EnvConfig, + InstanceID: instanceID, + }) + if err != nil { + return "", fmt.Errorf("failed to initialize the actor state store: %w", err) + } + + tokenCtx, tokenCancel := context.WithTimeout(ctx, 10*time.Second) + defer tokenCancel() + + token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, userID, time.Hour, false) + if err != nil { + return "", err + } + + return token, nil +} + func init() { rootCmd.AddCommand(oneTimeAccessTokenCmd) } diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 667dc9db..eb7f4727 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -99,8 +99,15 @@ type EnvConfigSchema struct { // Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to, and in that case Pocket ID does not start an embedded runtime FrancisHost string `env:"FRANCIS_HOST" options:"toLower"` // FrancisHostPSK is the pre-shared key Pocket ID presents to a standalone Francis runtime when joining the cluster - // It must match the "bootstrap.hostPSK" value in the runtime's own configuration, and it is required whenever FrancisHost points to a standalone runtime + // It must match the "bootstrap.hostPSK" value in the runtime's own configuration + // It is one of the three ways to authenticate to the runtime, and exactly one of them is required whenever FrancisHost points to a standalone runtime FrancisHostPSK []byte `env:"FRANCIS_HOST_PSK" options:"file"` + // FrancisHostJWT is the bearer token Pocket ID presents to a standalone Francis runtime configured for JWT bootstrap + // Prefer FrancisHostJWTFile in production, since a token passed inline cannot be rotated without restarting Pocket ID + FrancisHostJWT string `env:"FRANCIS_HOST_JWT"` + // FrancisHostJWTFile is the path to a file holding the bearer token Pocket ID presents to a standalone Francis runtime + // Unlike the other "_FILE" variables this one keeps the path rather than the contents: the file is re-read on every connection to the runtime, so a rotated token (such as a Kubernetes projected service account token) is picked up without restarting Pocket ID + FrancisHostJWTFile string `env:"FRANCIS_HOST_JWT_FILE"` // FrancisCA is the PEM-encoded cluster CA of a standalone Francis runtime, which Pocket ID pins before its first connection // Leaving it empty makes Pocket ID trust the certificate the runtime presents on the first connection, which is vulnerable to an attacker intercepting that connection FrancisCA []byte `env:"FRANCIS_CA" options:"file"` @@ -281,16 +288,13 @@ func prepareFrancisConfig(config *EnvConfigSchema) error { return errors.New("FRANCIS_HOST does not contain any address") } - // The key has to match the runtime's byte-for-byte, and reading it from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here + // Credentials have to match the runtime's byte-for-byte, and reading one from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here config.FrancisHostPSK = bytes.TrimSpace(config.FrancisHostPSK) + config.FrancisHostJWT = strings.TrimSpace(config.FrancisHostJWT) - // A standalone runtime only admits hosts that present the bootstrap pre-shared key it is configured with - // Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup - switch { - case len(config.FrancisHostPSK) == 0: - return errors.New("FRANCIS_HOST_PSK is required when FRANCIS_HOST points to a standalone Francis runtime") - case len(config.FrancisHostPSK) < francisHostPSKMinLength: - return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength) + err := validateFrancisBootstrap(config) + if err != nil { + return err } config.FrancisAddresses = addresses @@ -298,6 +302,45 @@ func prepareFrancisConfig(config *EnvConfigSchema) error { return nil } +// validateFrancisBootstrap checks the credential Pocket ID presents when joining a standalone Francis runtime +// The runtime admits a host through exactly one bootstrap method, so configuring none or more than one is a configuration error rather than something to resolve by picking a winner +func validateFrancisBootstrap(config *EnvConfigSchema) error { + configured := make([]string, 0, 3) + if len(config.FrancisHostPSK) > 0 { + configured = append(configured, "FRANCIS_HOST_PSK") + } + if config.FrancisHostJWT != "" { + configured = append(configured, "FRANCIS_HOST_JWT") + } + if config.FrancisHostJWTFile != "" { + configured = append(configured, "FRANCIS_HOST_JWT_FILE") + } + + switch len(configured) { + case 1: + // Exactly one method, which is what the runtime expects + case 0: + return errors.New("one of FRANCIS_HOST_PSK, FRANCIS_HOST_JWT, or FRANCIS_HOST_JWT_FILE is required when FRANCIS_HOST points to a standalone Francis runtime") + default: + return fmt.Errorf("only one host bootstrap method may be configured, but %s are all set", strings.Join(configured, ", ")) + } + + // Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup + if len(config.FrancisHostPSK) > 0 && len(config.FrancisHostPSK) < francisHostPSKMinLength { + return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength) + } + + // A token read on every connection is useless if the file is not there when Pocket ID starts + if config.FrancisHostJWTFile != "" { + _, err := os.Stat(config.FrancisHostJWTFile) + if err != nil { + return fmt.Errorf("FRANCIS_HOST_JWT_FILE not found: %w", err) + } + } + + return nil +} + // HasEmbeddedFrancisRuntime returns true when Pocket ID runs the Francis actor runtime inside its own process, which is the case unless FRANCIS_HOST points to a standalone runtime func (config *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool { return len(config.FrancisAddresses) == 0 diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index 092215bb..01c34578 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -440,13 +440,61 @@ func TestFrancisHostConfig(t *testing.T) { assert.ErrorContains(t, err, "FRANCIS_HOST does not contain any address") }) - t.Run("should fail when the bootstrap PSK is missing", func(t *testing.T) { + t.Run("should fail when no bootstrap method is configured", func(t *testing.T) { setBaseEnv(t) t.Setenv("FRANCIS_HOST", "francis.example.com:8443") err := parseAndValidateEnvConfig(t) require.Error(t, err) - assert.ErrorContains(t, err, "FRANCIS_HOST_PSK is required") + assert.ErrorContains(t, err, "one of FRANCIS_HOST_PSK, FRANCIS_HOST_JWT, or FRANCIS_HOST_JWT_FILE is required") + }) + + t.Run("should accept a bootstrap JWT", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT", " header.payload.signature\n") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, "header.payload.signature", EnvConfig.FrancisHostJWT) + assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should accept a bootstrap JWT file and keep its path", func(t *testing.T) { + setBaseEnv(t) + + // The path is what gets stored, not the contents: Francis re-reads the file on every connection so a rotated token is picked up + jwtFile := t.TempDir() + "/token" + require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600)) + + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT_FILE", jwtFile) + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, jwtFile, EnvConfig.FrancisHostJWTFile) + assert.Empty(t, EnvConfig.FrancisHostJWT) + }) + + t.Run("should fail when the bootstrap JWT file does not exist", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT_FILE", "/nonexistent/token") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "FRANCIS_HOST_JWT_FILE not found") + }) + + t.Run("should fail when more than one bootstrap method is configured", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough") + t.Setenv("FRANCIS_HOST_JWT", "header.payload.signature") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "only one host bootstrap method may be configured, but FRANCIS_HOST_PSK, FRANCIS_HOST_JWT are all set") }) t.Run("should fail when the bootstrap PSK is too short", func(t *testing.T) {