From 9b0e299a89550266c89f441743f7019aa17ebf30 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Mon, 15 Jun 2026 21:32:44 +0200 Subject: [PATCH 1/2] feat: configurable expiry for temporary proxy/tunnel API keys The Ory Proxy and Tunnel create a temporary project API key to configure your project. These keys were created without expiry, so if the cleanup on shutdown failed (e.g. the process was killed) the key would linger indefinitely. Add an --api-key-expiry flag to both commands that sets a server-side expiry on the temporary key, ensuring it is removed automatically even when local cleanup does not run. Defaults to 12h; set to 0 to disable. --- cmd/cloudx/client/api_key.go | 22 ++++++++++++++++++---- cmd/cloudx/client/command_helper_test.go | 11 +++++++---- cmd/cloudx/proxy/helpers.go | 13 ++++++++++++- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/cmd/cloudx/client/api_key.go b/cmd/cloudx/client/api_key.go index 978d119c..202b8658 100644 --- a/cmd/cloudx/client/api_key.go +++ b/cmd/cloudx/client/api_key.go @@ -7,18 +7,28 @@ import ( "context" "errors" "fmt" + "time" cloud "github.com/ory/client-go" "github.com/ory/x/cmdx" ) -func (h *CommandHelper) CreateProjectAPIKey(ctx context.Context, projectID, name string) (*cloud.ProjectApiKey, error) { +// CreateProjectAPIKey creates a project API key. If expiresIn is greater than +// zero, the key is set to expire that duration from now so it is cleaned up +// automatically on the server side even if local cleanup fails. +func (h *CommandHelper) CreateProjectAPIKey(ctx context.Context, projectID, name string, expiresIn time.Duration) (*cloud.ProjectApiKey, error) { c, err := h.newConsoleAPIClient(ctx) if err != nil { return nil, err } - token, res, err := c.ProjectAPI.CreateProjectApiKey(ctx, projectID).CreateProjectApiKeyRequest(cloud.CreateProjectApiKeyRequest{Name: name}).Execute() + req := cloud.CreateProjectApiKeyRequest{Name: name} + if expiresIn > 0 { + expiresAt := time.Now().Add(expiresIn) + req.ExpiresAt = &expiresAt + } + + token, res, err := c.ProjectAPI.CreateProjectApiKey(ctx, projectID).CreateProjectApiKeyRequest(req).Execute() if err != nil { return nil, handleError("unable to create project API key", res, err) } @@ -64,7 +74,11 @@ func (h *CommandHelper) DeleteWorkspaceAPIKey(ctx context.Context, workspaceID, return nil } -func (h *CommandHelper) TemporaryAPIKey(ctx context.Context, name string) (apiKey string, cleanup func() error, err error) { +// TemporaryAPIKey creates a short-lived project API key that is deleted via the +// returned cleanup function. The key is additionally set to expire after +// expiresIn so that it is removed automatically should the cleanup fail. An +// expiresIn of zero creates a key without expiry. +func (h *CommandHelper) TemporaryAPIKey(ctx context.Context, name string, expiresIn time.Duration) (apiKey string, cleanup func() error, err error) { if h.projectAPIKey != nil { return *h.projectAPIKey, noop, nil } @@ -95,7 +109,7 @@ func (h *CommandHelper) TemporaryAPIKey(ctx context.Context, name string) (apiKe if err != nil { return "", noop, err } - ak, err := h.CreateProjectAPIKey(ctx, projectID, name) + ak, err := h.CreateProjectAPIKey(ctx, projectID, name, expiresIn) if err != nil { _, _ = fmt.Fprintf(h.VerboseErrWriter, "Unable to create API key. Do you have the required permissions to use the Ory CLI with project %q? Continuing without API key.", projectID) return "", noop, nil diff --git a/cmd/cloudx/client/command_helper_test.go b/cmd/cloudx/client/command_helper_test.go index 489117ec..a5dc9bef 100644 --- a/cmd/cloudx/client/command_helper_test.go +++ b/cmd/cloudx/client/command_helper_test.go @@ -155,7 +155,7 @@ func TestCommandHelper(t *testing.T) { require.Len(t, projects, 2) assert.ElementsMatch(t, []string{p0.Id, p1.Id}, []string{projects[0].Id, projects[1].Id}) - pjKey, err := authenticated.CreateProjectAPIKey(ctx, p0.Id, "test key") + pjKey, err := authenticated.CreateProjectAPIKey(ctx, p0.Id, "test key", 0) require.NoError(t, err) pjKeyH, err := client.NewCommandHelper(ctx, client.WithProjectAPIKey(*pjKey.Value)) @@ -192,7 +192,7 @@ func TestCommandHelper(t *testing.T) { require.Len(t, projects, 2) assert.ElementsMatch(t, []string{p0.Id, p1.Id}, []string{projects[0].Id, projects[1].Id}) - pjKey, err := authenticated.CreateProjectAPIKey(ctx, p0.Id, "test key") + pjKey, err := authenticated.CreateProjectAPIKey(ctx, p0.Id, "test key", 0) require.NoError(t, err) pjKeyH, err := client.NewCommandHelper(ctx, client.WithProjectAPIKey(*pjKey.Value)) @@ -310,10 +310,13 @@ func TestCommandHelper(t *testing.T) { keyName := "a test key" - key, err := authenticated.CreateProjectAPIKey(ctx, defaultProject.Id, keyName) + key, err := authenticated.CreateProjectAPIKey(ctx, defaultProject.Id, keyName, time.Hour) require.NoError(t, err) assert.Equal(t, keyName, key.Name) assert.NotNil(t, keyName, key.Value) + // The requested expiry should be reflected on the returned key. + require.NotNil(t, key.ExpiresAt) + assert.WithinDuration(t, time.Now().Add(time.Hour), *key.ExpiresAt, 5*time.Minute) // check that the key works ctxWithKey := client.ContextWithOptions(ctx, @@ -333,7 +336,7 @@ func TestCommandHelper(t *testing.T) { t.Run("func=GetProject", func(t *testing.T) { wsKeyH, err := client.NewCommandHelper(ctx, client.WithWorkspaceAPIKey(*defaultWorkspaceAPIKey.Value)) require.NoError(t, err) - pjKey, err := authenticated.CreateProjectAPIKey(ctx, defaultProject.Id, "test key") + pjKey, err := authenticated.CreateProjectAPIKey(ctx, defaultProject.Id, "test key", 0) require.NoError(t, err) pjKeyH, err := client.NewCommandHelper(ctx, client.WithProjectAPIKey(*pjKey.Value)) require.NoError(t, err) diff --git a/cmd/cloudx/proxy/helpers.go b/cmd/cloudx/proxy/helpers.go index 01690921..8f2b4951 100644 --- a/cmd/cloudx/proxy/helpers.go +++ b/cmd/cloudx/proxy/helpers.go @@ -53,8 +53,13 @@ const ( CORSFlag = "allowed-cors-origins" AdditionalCORSHeadersFlag = "additional-cors-headers" RewriteHostFlag = "rewrite-host" + APIKeyExpiryFlag = "api-key-expiry" ) +// defaultAPIKeyExpiry is the default lifetime of the temporary API key the +// proxy and tunnel create to configure the project. +const defaultAPIKeyExpiry = 12 * time.Hour + type config struct { port int open bool @@ -70,6 +75,11 @@ type config struct { corsOrigins []string additionalCorsHeaders []string + // apiKeyExpiry is the lifetime of the temporary API key the proxy/tunnel + // creates. The key is deleted on shutdown; the expiry ensures it is removed + // automatically should that cleanup fail. A value of zero disables expiry. + apiKeyExpiry time.Duration + // rewriteHost means the host header will be rewritten to the upstream host. // This is useful in cases where upstream resolves requests based on Host. rewriteHost bool @@ -89,6 +99,7 @@ func registerConfigFlags(conf *config, flags *pflag.FlagSet) { flags.BoolVar(&conf.isDev, DevFlag, true, "This flag is deprecated as the command is only supposed to be used during development.") flags.BoolVar(&conf.isDebug, DebugFlag, false, "Use this flag to debug, for example, CORS requests.") flags.BoolVar(&conf.rewriteHost, RewriteHostFlag, false, "Use this flag to rewrite the host header to the upstream host.") + flags.DurationVar(&conf.apiKeyExpiry, APIKeyExpiryFlag, defaultAPIKeyExpiry, "Sets the expiry of the temporary API key the Ory CLI creates to configure your project. The key is deleted on shutdown; this expiry ensures it is removed automatically if that cleanup fails. Set to 0 to disable expiry.") } func portFromEnv() int { @@ -115,7 +126,7 @@ func runReverseProxy(ctx context.Context, h *client.CommandHelper, stdErr io.Wri return err } - apiKey, removeAPIKey, err := h.TemporaryAPIKey(ctx, fmt.Sprintf("Ory %s temporary API key - %s", name, h.UserName(ctx))) + apiKey, removeAPIKey, err := h.TemporaryAPIKey(ctx, fmt.Sprintf("Ory %s temporary API key - %s", name, h.UserName(ctx)), conf.apiKeyExpiry) if err != nil { return err } From 8605fd9e48a1ee9052fa094cde2c5a02722526e2 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Mon, 15 Jun 2026 22:00:02 +0200 Subject: [PATCH 2/2] fix: reject negative API key expiry Previously a negative --api-key-expiry was silently treated as "no expiry", which could let callers create non-expiring keys by mistake. Return an error for negative durations instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/cloudx/client/api_key.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/cloudx/client/api_key.go b/cmd/cloudx/client/api_key.go index 202b8658..34a2e341 100644 --- a/cmd/cloudx/client/api_key.go +++ b/cmd/cloudx/client/api_key.go @@ -15,8 +15,13 @@ import ( // CreateProjectAPIKey creates a project API key. If expiresIn is greater than // zero, the key is set to expire that duration from now so it is cleaned up -// automatically on the server side even if local cleanup fails. +// automatically on the server side even if local cleanup fails. An expiresIn of +// zero creates a key without expiry; a negative value is rejected. func (h *CommandHelper) CreateProjectAPIKey(ctx context.Context, projectID, name string, expiresIn time.Duration) (*cloud.ProjectApiKey, error) { + if expiresIn < 0 { + return nil, errors.New("API key expiry must not be negative") + } + c, err := h.newConsoleAPIClient(ctx) if err != nil { return nil, err