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 diff --git a/cmd/auth.go b/cmd/auth.go index 2789297..aba6eec 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -2,20 +2,22 @@ package cmd import ( "bytes" - "context" "crypto/tls" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "os" + "strings" "time" "github.com/retyc/retyc-cli/internal/api" "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{ @@ -23,6 +25,8 @@ var authCmd = &cobra.Command{ Short: "Manage authentication", } +var offlineLogin bool + var authLoginCmd = &cobra.Command{ Use: "login", Short: "Authenticate using OIDC device flow", @@ -32,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) @@ -40,11 +44,30 @@ 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) } + 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):") + fmt.Println(token.RefreshToken) + return nil + } + if err := config.SaveToken(token); err != nil { return fmt.Errorf("saving token: %w", err) } @@ -56,8 +79,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 := cmd.Context() + 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) } @@ -75,14 +117,20 @@ 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() + ctx := cmd.Context() httpClient := newHTTPClient(insecure, debug) oidcCfg, err := api.FetchOIDCConfig(ctx, cfg.API.BaseURL, httpClient) @@ -103,12 +151,22 @@ 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.") } - fmt.Printf("Authenticated (expires: %s)\n", tok.Expiry.Format("2006-01-02 15:04:05")) + // 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")) + } return nil }, } @@ -165,7 +223,29 @@ 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 + } + decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) + 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) authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authStatusCmd) diff --git a/cmd/transfer.go b/cmd/transfer.go index 3918cea..4945e00 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,25 @@ 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") + 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 +866,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") } diff --git a/internal/api/login.go b/internal/api/login.go index b42057c..5f9384b 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 @@ -43,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)) } @@ -67,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)) } @@ -82,5 +89,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 7546835..28b4dcf 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "time" @@ -228,15 +229,66 @@ 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, 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)) + } + + return nil +} + // 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 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.