From d03e16a6a73242c74d70ed6a4b0f9d5ed9f1fb4a Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 16:53:19 +0100 Subject: [PATCH 01/12] :rocket: Add offline auth --- cmd/auth.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cmd/auth.go b/cmd/auth.go index 2789297..2bccf04 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -23,6 +23,8 @@ var authCmd = &cobra.Command{ Short: "Manage authentication", } +var offlineLogin bool + var authLoginCmd = &cobra.Command{ Use: "login", Short: "Authenticate using OIDC device flow", @@ -40,6 +42,12 @@ var authLoginCmd = &cobra.Command{ return fmt.Errorf("fetching OIDC config: %w", err) } + // In offline mode, request a long-lived offline token (refresh token) + // suitable for non-interactive use in CI/CD pipelines. + if offlineLogin { + oidcCfg.Scopes = append(oidcCfg.Scopes, "offline_access") + } + token, err := auth.DeviceFlow(ctx, *oidcCfg, httpClient) if err != nil { return fmt.Errorf("device flow: %w", err) @@ -49,6 +57,17 @@ var authLoginCmd = &cobra.Command{ return fmt.Errorf("saving token: %w", err) } + if offlineLogin { + if token.RefreshToken == "" { + return fmt.Errorf("server did not return an offline token (check that offline_access scope is supported)") + } + fmt.Println("Authentication successful.") + fmt.Println() + fmt.Println("Offline token (set as RETYC_TOKEN in CI):") + fmt.Println(token.RefreshToken) + return nil + } + fmt.Println("Authentication successful.") return nil }, @@ -166,6 +185,7 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { } func init() { + authLoginCmd.Flags().BoolVar(&offlineLogin, "offline", false, "Request an offline token for non-interactive use (CI/CD)") authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authStatusCmd) From 24f506d4dac757403c980b0ecb30abadada4ec78 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 17:00:03 +0100 Subject: [PATCH 02/12] :rocket: Offline login with env variable --- internal/auth/oidc.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 7546835..61627d2 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "time" @@ -230,13 +231,27 @@ func Refresh(ctx context.Context, cfg config.OIDCConfig, refreshToken string, ht // GetValidToken returns a valid token for the current session. // -// It loads the stored token from disk and returns it immediately if it is -// still valid. If it has expired and a refresh token is available, it +// If the RETYC_TOKEN environment variable is set, it is treated as an offline +// refresh token: it is exchanged for a fresh access token without reading from +// or writing to disk. This is the intended path for non-interactive CI/CD use. +// +// Otherwise it loads the stored token from disk and returns it immediately if +// it is still valid. If it has expired and a refresh token is available, it // attempts a silent refresh and persists the new token before returning it. // // Callers should handle ErrNoToken (not authenticated) and ErrNoRefreshToken // (expired, must re-authenticate via DeviceFlow) as non-fatal states. func GetValidToken(ctx context.Context, cfg config.OIDCConfig, httpClient *http.Client) (*oauth2.Token, error) { + // CI/CD path: RETYC_TOKEN holds an offline refresh token. + // Exchange it for a fresh access token without touching disk. + if envToken := os.Getenv("RETYC_TOKEN"); envToken != "" { + tok, err := Refresh(ctx, cfg, envToken, httpClient) + if err != nil { + return nil, fmt.Errorf("RETYC_TOKEN refresh failed: %w", err) + } + return tok, nil + } + tok, err := config.LoadToken() if err != nil { return nil, ErrNoToken From f93ead771b05a65ba2ee9ce5e7f37f6392f774ed Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 17:05:33 +0100 Subject: [PATCH 03/12] :rocket: Read key passphrase with env var --- cmd/transfer.go | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/cmd/transfer.go b/cmd/transfer.go index 3918cea..0ece78a 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -193,16 +193,13 @@ var transferInfoCmd = &cobra.Command{ } if identityStr == "" { - // Prompt passphrase without echo, then erase the prompt line. - fmt.Fprint(os.Stderr, "Key passphrase: ") - passphraseBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprint(os.Stderr, "\r\033[2K") + passphrase, err := readKeyPassphrase() if err != nil { - return fmt.Errorf("reading key passphrase: %w", err) + return err } // Decrypt user's AGE private key (scrypt). - identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, string(passphraseBytes)) + identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, passphrase) if err != nil { return fmt.Errorf("wrong key passphrase") } @@ -269,6 +266,21 @@ var transferInfoCmd = &cobra.Command{ }, } +// readKeyPassphrase returns the key passphrase from the RETYC_KEY_PASSPHRASE +// environment variable, or prompts the user interactively if the variable is not set. +func readKeyPassphrase() (string, error) { + if v := os.Getenv("RETYC_KEY_PASSPHRASE"); v != "" { + return v, nil + } + fmt.Fprint(os.Stderr, "Key passphrase: ") + pb, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprint(os.Stderr, "\r\033[2K") + if err != nil { + return "", fmt.Errorf("reading key passphrase: %w", err) + } + return string(pb), nil +} + // mustGetToken retrieves a valid OAuth2 token, returning a user-friendly error // if authentication is missing or expired. func mustGetToken(ctx context.Context, cfg *config.Config) (*oauth2.Token, error) { @@ -850,13 +862,11 @@ var transferDownloadCmd = &cobra.Command{ identityStr, _ = keyring.Load() } if identityStr == "" { - fmt.Fprint(os.Stderr, "Key passphrase: ") - pb, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprint(os.Stderr, "\r\033[2K") + passphrase, err := readKeyPassphrase() if err != nil { - return fmt.Errorf("reading key passphrase: %w", err) + return err } - identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, string(pb)) + identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, passphrase) if err != nil { return fmt.Errorf("wrong key passphrase") } From 76d01c8ac54145c3b6a0b6f27005b3601a3573e7 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 17:08:53 +0100 Subject: [PATCH 04/12] :memo: Add notes about CI/CD usage --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 52b70b5..f9c50bc 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ retyc transfer download | Command | Description | |---|---| | `retyc auth login` | Authenticate via OIDC device flow | +| `retyc auth login --offline` | Authenticate and print an offline token for CI/CD use | | `retyc auth status` | Check authentication status (silently refreshes token) | | `retyc auth logout` | Sign out | @@ -112,6 +113,43 @@ docker run -it --rm \ --- +## CI / CD + +`retyc-cli` can run fully non-interactively for authentication and key-unlock flows in pipelines. Set the following environment variables to avoid credential and key passphrase prompts: + +| Variable | Description | +|---|---| +| `RETYC_TOKEN` | Offline refresh token used instead of reading credentials from disk | +| `RETYC_KEY_PASSPHRASE` | Passphrase for your AGE private key, used instead of an interactive passphrase prompt | + +> **Note:** Other interactive prompts (for example, transfer confirmation unless you pass `-y`) may still appear and must be disabled using the appropriate CLI flags when running in CI. + +### Setup (one-time, on your machine) + +```sh +# Authenticate and print an offline token +retyc auth login --offline +``` + +Copy the printed token and store it as a secret in your CI provider alongside your key passphrase. + +### Usage in a pipeline + +```sh +export RETYC_TOKEN= +export RETYC_KEY_PASSPHRASE= + +# Send build artifacts +retyc transfer create -y --title "Release v1.2.3" ./dist/app.tar.gz + +# Download a transfer +retyc transfer download -y +``` + +The offline token is a long-lived refresh token. At each invocation the CLI exchanges it for a short-lived access token — nothing is written to disk. + +--- + ## Configuration Credentials and config are stored in a platform-specific directory: @@ -127,6 +165,14 @@ Override at any time: export RETYC_CONFIG_DIR=/path/to/config ``` +### Environment variables + +| Variable | Description | +|---|---| +| `RETYC_CONFIG_DIR` | Override the config directory | +| `RETYC_TOKEN` | Offline refresh token (bypasses disk credentials — see [CI / CD](#ci--cd)) | +| `RETYC_KEY_PASSPHRASE` | AGE key passphrase (bypasses interactive prompt — see [CI / CD](#ci--cd)) | + Create `config.yaml` to override defaults: ```yaml From 9e8cdcfc1b47ddb9d7be423b5b586c5fbe3946f0 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 17:23:38 +0100 Subject: [PATCH 05/12] :lock: When logout, close session --- cmd/auth.go | 21 ++++++++++++++++++++- internal/api/login.go | 2 ++ internal/auth/oidc.go | 34 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 11 ++++++----- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 2bccf04..080948b 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -75,8 +75,27 @@ var authLoginCmd = &cobra.Command{ var authLogoutCmd = &cobra.Command{ Use: "logout", - Short: "Remove stored credentials", + Short: "Revoke server-side token and remove stored credentials", RunE: func(cmd *cobra.Command, args []string) error { + // Attempt server-side revocation before deleting the local token. + // Failures are non-fatal: local credentials are always cleaned up. + tok, err := config.LoadToken() + if err == nil && tok.RefreshToken != "" { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: loading config: %v\n", err) + } else { + ctx := context.Background() + httpClient := newHTTPClient(insecure, debug) + oidcCfg, err := api.FetchOIDCConfig(ctx, cfg.API.BaseURL, httpClient) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: fetching OIDC config: %v\n", err) + } else if err := auth.Revoke(ctx, *oidcCfg, tok.RefreshToken, httpClient); err != nil { + fmt.Fprintf(os.Stderr, "warning: revoking token: %v\n", err) + } + } + } + if err := config.DeleteToken(); err != nil { return fmt.Errorf("removing token: %w", err) } diff --git a/internal/api/login.go b/internal/api/login.go index b42057c..34aced6 100644 --- a/internal/api/login.go +++ b/internal/api/login.go @@ -23,6 +23,7 @@ type publicLoginConfig struct { type oidcDiscovery struct { DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` } // FetchOIDCConfig retrieves the public OIDC configuration from POST /login/config/public @@ -82,5 +83,6 @@ func FetchOIDCConfig(ctx context.Context, baseURL string, httpClient *http.Clien Scopes: pub.Scopes, DeviceAuthURL: disc.DeviceAuthorizationEndpoint, TokenURL: disc.TokenEndpoint, + EndSessionURL: disc.EndSessionEndpoint, }, nil } diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 61627d2..c9b242c 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -229,6 +229,40 @@ func Refresh(ctx context.Context, cfg config.OIDCConfig, refreshToken string, ht return tok, nil } +// Revoke terminates the server-side session by calling the OIDC end_session +// endpoint with the refresh token (Keycloak backchannel logout). +// Unlike RFC 7009 token revocation, this actually closes the session visible +// in the identity provider's admin panel. +func Revoke(ctx context.Context, cfg config.OIDCConfig, refreshToken string, httpClient *http.Client) error { + if cfg.EndSessionURL == "" { + return fmt.Errorf("OIDC end_session endpoint not available") + } + + data := url.Values{ + "client_id": {cfg.ClientID}, + "refresh_token": {refreshToken}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.EndSessionURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("end_session endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + // GetValidToken returns a valid token for the current session. // // If the RETYC_TOKEN environment variable is set, it is treated as an offline diff --git a/internal/config/config.go b/internal/config/config.go index 879d064..b951bf5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,11 +21,12 @@ func ConfigDir() (string, error) { // OIDCConfig holds the parameters needed to perform an OIDC device flow. type OIDCConfig struct { - Issuer string `yaml:"issuer" mapstructure:"issuer"` - ClientID string `yaml:"client_id" mapstructure:"client_id"` - Scopes []string `yaml:"scopes" mapstructure:"scopes"` - DeviceAuthURL string `yaml:"device_auth_url" mapstructure:"device_auth_url"` - TokenURL string `yaml:"token_url" mapstructure:"token_url"` + Issuer string `yaml:"issuer" mapstructure:"issuer"` + ClientID string `yaml:"client_id" mapstructure:"client_id"` + Scopes []string `yaml:"scopes" mapstructure:"scopes"` + DeviceAuthURL string `yaml:"device_auth_url" mapstructure:"device_auth_url"` + TokenURL string `yaml:"token_url" mapstructure:"token_url"` + EndSessionURL string `yaml:"end_session_url" mapstructure:"end_session_url"` } // APIConfig holds REST API connection parameters. From 3c0bb3e536f02f010741469f6b51aea121338deb Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 17:33:56 +0100 Subject: [PATCH 06/12] :rocket: Add info about token (offline session) --- cmd/auth.go | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/cmd/auth.go b/cmd/auth.go index 080948b..bcec938 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "os" + "strings" "time" "github.com/retyc/retyc-cli/internal/api" @@ -146,7 +148,11 @@ var authStatusCmd = &cobra.Command{ fmt.Println("Token was expired and has been refreshed silently.") } - fmt.Printf("Authenticated (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) + if isOfflineToken(stored.RefreshToken) { + fmt.Printf("Authenticated — offline token (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) + } else { + fmt.Printf("Authenticated (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) + } return nil }, } @@ -203,6 +209,37 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { return resp, nil } +// isOfflineToken reports whether the given JWT refresh token is a Keycloak +// offline token by decoding its payload and checking for typ == "Offline". +// Returns false on any parse error. +func isOfflineToken(refreshToken string) bool { + parts := strings.SplitN(refreshToken, ".", 3) + if len(parts) != 3 { + return false + } + // base64url → base64 standard, add padding + payload := parts[1] + payload = strings.ReplaceAll(payload, "-", "+") + payload = strings.ReplaceAll(payload, "_", "/") + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return false + } + var claims struct { + Typ string `json:"typ"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + return false + } + return strings.EqualFold(claims.Typ, "Offline") +} + func init() { authLoginCmd.Flags().BoolVar(&offlineLogin, "offline", false, "Request an offline token for non-interactive use (CI/CD)") authCmd.AddCommand(authLoginCmd) From db5a10cf84c03080d8dd3b24cc59d9a5c8deb6eb Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sat, 28 Feb 2026 19:13:22 +0100 Subject: [PATCH 07/12] :bug: Offline login must not store token in disk --- cmd/auth.go | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index bcec938..f945761 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -18,6 +18,7 @@ import ( "github.com/retyc/retyc-cli/internal/auth" "github.com/retyc/retyc-cli/internal/config" "github.com/spf13/cobra" + "golang.org/x/oauth2" ) var authCmd = &cobra.Command{ @@ -55,14 +56,12 @@ var authLoginCmd = &cobra.Command{ return fmt.Errorf("device flow: %w", err) } - if err := config.SaveToken(token); err != nil { - return fmt.Errorf("saving token: %w", err) - } - if offlineLogin { if token.RefreshToken == "" { return fmt.Errorf("server did not return an offline token (check that offline_access scope is supported)") } + // Do not persist to disk: the offline token is intended to be copied + // into RETYC_TOKEN and used non-interactively in CI/CD pipelines. fmt.Println("Authentication successful.") fmt.Println() fmt.Println("Offline token (set as RETYC_TOKEN in CI):") @@ -70,6 +69,10 @@ var authLoginCmd = &cobra.Command{ return nil } + if err := config.SaveToken(token); err != nil { + return fmt.Errorf("saving token: %w", err) + } + fmt.Println("Authentication successful.") return nil }, @@ -115,11 +118,17 @@ var authStatusCmd = &cobra.Command{ return fmt.Errorf("loading config: %w", err) } - // Load the raw token first to detect whether a refresh occurred. - stored, err := config.LoadToken() - if err != nil { - fmt.Println("Not authenticated. Run `retyc auth login`.") - return nil + envToken := os.Getenv("RETYC_TOKEN") + + // Load the stored token from disk to detect silent refreshes and token type. + // Skipped when RETYC_TOKEN is set (no local credentials in that mode). + var stored *oauth2.Token + if envToken == "" { + stored, err = config.LoadToken() + if err != nil { + fmt.Println("Not authenticated. Run `retyc auth login`.") + return nil + } } ctx := context.Background() @@ -143,12 +152,18 @@ var authStatusCmd = &cobra.Command{ return nil } - // Inform the user when a silent refresh happened. - if !stored.Valid() { + // Inform the user when a silent refresh happened (disk token path only). + if stored != nil && !stored.Valid() { fmt.Println("Token was expired and has been refreshed silently.") } - if isOfflineToken(stored.RefreshToken) { + // Determine the refresh token to inspect: disk token or RETYC_TOKEN env var. + refreshToken := envToken + if stored != nil { + refreshToken = stored.RefreshToken + } + + if isOfflineToken(refreshToken) { fmt.Printf("Authenticated — offline token (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) } else { fmt.Printf("Authenticated (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) From 08aba63d6ff9f0603ce004e739140ccb8bff4ee8 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sun, 1 Mar 2026 09:14:24 +0100 Subject: [PATCH 08/12] :art: Better decode jwt token --- cmd/auth.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index f945761..33adff7 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -232,17 +232,7 @@ func isOfflineToken(refreshToken string) bool { if len(parts) != 3 { return false } - // base64url → base64 standard, add padding - payload := parts[1] - payload = strings.ReplaceAll(payload, "-", "+") - payload = strings.ReplaceAll(payload, "_", "/") - switch len(payload) % 4 { - case 2: - payload += "==" - case 3: - payload += "=" - } - decoded, err := base64.StdEncoding.DecodeString(payload) + decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return false } From b553c955af9c5b447e1cecce12855395eee3bc80 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sun, 1 Mar 2026 09:18:26 +0100 Subject: [PATCH 09/12] :art: Manage error while reading body --- internal/api/login.go | 10 ++++++++-- internal/auth/oidc.go | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/api/login.go b/internal/api/login.go index 34aced6..5f9384b 100644 --- a/internal/api/login.go +++ b/internal/api/login.go @@ -44,7 +44,10 @@ func FetchOIDCConfig(ctx context.Context, baseURL string, httpClient *http.Clien defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("public login config: API error %d (could not read body: %w)", resp.StatusCode, readErr) + } return nil, fmt.Errorf("public login config: API error %d: %s", resp.StatusCode, string(body)) } @@ -68,7 +71,10 @@ func FetchOIDCConfig(ctx context.Context, baseURL string, httpClient *http.Clien defer resp2.Body.Close() if resp2.StatusCode < 200 || resp2.StatusCode >= 300 { - body, _ := io.ReadAll(resp2.Body) + body, readErr := io.ReadAll(resp2.Body) + if readErr != nil { + return nil, fmt.Errorf("OIDC discovery: API error %d (could not read body: %w)", resp2.StatusCode, readErr) + } return nil, fmt.Errorf("OIDC discovery: API error %d: %s", resp2.StatusCode, string(body)) } diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index c9b242c..28b4dcf 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -256,7 +256,10 @@ func Revoke(ctx context.Context, cfg config.OIDCConfig, refreshToken string, htt defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("end_session endpoint returned %d (could not read body: %w)", resp.StatusCode, readErr) + } return fmt.Errorf("end_session endpoint returned %d: %s", resp.StatusCode, string(body)) } From 038620c630aa1e3d52a635595ab7d75c11ce17e7 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sun, 1 Mar 2026 09:20:55 +0100 Subject: [PATCH 10/12] :rotating_light: Use cmd.Context() instead of context.Background() in auth commands --- cmd/auth.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 33adff7..aba6eec 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -2,7 +2,6 @@ package cmd import ( "bytes" - "context" "crypto/tls" "encoding/base64" "encoding/json" @@ -37,7 +36,7 @@ var authLoginCmd = &cobra.Command{ return fmt.Errorf("loading config: %w", err) } - ctx := context.Background() + ctx := cmd.Context() httpClient := newHTTPClient(insecure, debug) oidcCfg, err := api.FetchOIDCConfig(ctx, cfg.API.BaseURL, httpClient) @@ -90,7 +89,7 @@ var authLogoutCmd = &cobra.Command{ if err != nil { fmt.Fprintf(os.Stderr, "warning: loading config: %v\n", err) } else { - ctx := context.Background() + ctx := cmd.Context() httpClient := newHTTPClient(insecure, debug) oidcCfg, err := api.FetchOIDCConfig(ctx, cfg.API.BaseURL, httpClient) if err != nil { @@ -131,7 +130,7 @@ var authStatusCmd = &cobra.Command{ } } - ctx := context.Background() + ctx := cmd.Context() httpClient := newHTTPClient(insecure, debug) oidcCfg, err := api.FetchOIDCConfig(ctx, cfg.API.BaseURL, httpClient) From 564ca7856ac5e27a39f47f9f244122fac0cd8588 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Sun, 1 Mar 2026 09:31:38 +0100 Subject: [PATCH 11/12] :bug: Check tty before prompting passphrase --- cmd/transfer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/transfer.go b/cmd/transfer.go index 0ece78a..4945e00 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -268,10 +268,14 @@ var transferInfoCmd = &cobra.Command{ // readKeyPassphrase returns the key passphrase from the RETYC_KEY_PASSPHRASE // environment variable, or prompts the user interactively if the variable is not set. +// Returns a clear error when stdin is not a terminal and RETYC_KEY_PASSPHRASE is unset. func readKeyPassphrase() (string, error) { if v := os.Getenv("RETYC_KEY_PASSPHRASE"); v != "" { return v, nil } + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", fmt.Errorf("no TTY detected and RETYC_KEY_PASSPHRASE is not set") + } fmt.Fprint(os.Stderr, "Key passphrase: ") pb, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprint(os.Stderr, "\r\033[2K") From 617be96c9c89c9f071a6a06b912bc336358896ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:35:40 +0000 Subject: [PATCH 12/12] Initial plan