Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ retyc transfer download <transfer-id>
| 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 |

Expand Down Expand Up @@ -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=<offline_token>
export RETYC_KEY_PASSPHRASE=<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 <transfer-id>
```

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:
Expand All @@ -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
Expand Down
104 changes: 92 additions & 12 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@ 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{
Use: "auth",
Short: "Manage authentication",
}

var offlineLogin bool

var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate using OIDC device flow",
Expand All @@ -32,19 +36,38 @@ 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)
if err != nil {
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)
}
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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
},
}
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 25 additions & 11 deletions cmd/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Comment thread
HanXHX marked this conversation as resolved.
}
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) {
Expand Down Expand Up @@ -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")
}
Expand Down
12 changes: 10 additions & 2 deletions internal/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}

Expand All @@ -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))
}

Expand All @@ -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
}
Loading