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
4 changes: 0 additions & 4 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ jobs:
with:
go-version: ${{ matrix.go }}

- name: Run Generate
run: |
make generate

- name: Setup golangci-lint
uses: golangci/golangci-lint-action@v9
with:
Expand Down
175 changes: 80 additions & 95 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"crypto/tls"
"errors"
"flag"
"fmt"
"net/http"
"net/url"
Expand All @@ -18,35 +17,36 @@ import (
retry "github.com/appleboy/go-httpretry"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)

// version is set at build time via -ldflags "-X main.version=...".
var version string

var (
serverURL string
clientID string
clientSecret string
redirectURI string
callbackPort int
scope string
tokenFile string
tokenStoreMode string
forceDevice bool
configInitialized bool
retryClient *retry.Client
tokenStore credstore.Store[credstore.Token]
serverURL string
clientID string
clientSecret string
redirectURI string
callbackPort int
scope string
tokenFile string
tokenStoreMode string
forceDevice bool
storeConfigInitialized bool
configInitialized bool
retryClient *retry.Client
tokenStore credstore.Store[credstore.Token]

flagServerURL *string
flagClientID *string
flagClientSecret *string
flagRedirectURI *string
flagCallbackPort *int
flagScope *string
flagTokenFile *string
flagTokenStore *string
flagDevice *bool
flagVersion *bool
flagServerURL string
flagClientID string
flagClientSecret string
flagRedirectURI string
flagCallbackPort int
flagScope string
flagTokenFile string
flagTokenStore string
flagDevice bool
)

const (
Expand All @@ -58,47 +58,56 @@ const (
defaultKeyringService = "authgate-cli"
)

func init() {
func registerFlags(cmd *cobra.Command) {
_ = godotenv.Load()
cmd.PersistentFlags().
StringVar(&flagServerURL, "server-url", "", "OAuth server URL (default: http://localhost:8080 or SERVER_URL env)")
cmd.PersistentFlags().
StringVar(&flagClientID, "client-id", "", "OAuth client ID (required, or set CLIENT_ID env)")
cmd.PersistentFlags().
StringVar(&flagClientSecret, "client-secret", "", "OAuth client secret (confidential clients only; omit for public/PKCE clients)")
cmd.PersistentFlags().
StringVar(&flagRedirectURI, "redirect-uri", "", "Redirect URI registered with the OAuth server (default: http://localhost:PORT/callback)")
cmd.PersistentFlags().
IntVar(&flagCallbackPort, "port", 0, "Local callback port for browser flow (default: 8888 or CALLBACK_PORT env)")
cmd.PersistentFlags().
StringVar(&flagScope, "scope", "", "Space-separated OAuth scopes (default: \"read write\")")
cmd.PersistentFlags().
StringVar(&flagTokenFile, "token-file", "", "Token storage file (default: .authgate-tokens.json or TOKEN_FILE env)")
cmd.PersistentFlags().
StringVar(&flagTokenStore, "token-store", "", "Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)")
cmd.PersistentFlags().
BoolVar(&flagDevice, "device", false, "Force Device Code Flow (skip browser detection)")
}

// initStoreConfig initialises only the token store and client ID — the minimum
// needed for commands like `token get` that read local credentials without
// making any network calls.
func initStoreConfig() {
if storeConfigInitialized {
return
}
storeConfigInitialized = true

clientID = getConfig(flagClientID, "CLIENT_ID", "")
tokenFile = getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
tokenStoreMode = getConfig(flagTokenStore, "TOKEN_STORE", "auto")

if clientID == "" {
fmt.Fprintln(os.Stderr, "Error: CLIENT_ID not set. Please provide it via:")
fmt.Fprintln(os.Stderr, " 1. Command-line flag: --client-id=<your-client-id>")
fmt.Fprintln(os.Stderr, " 2. Environment variable: CLIENT_ID=<your-client-id>")
fmt.Fprintln(os.Stderr, " 3. .env file: CLIENT_ID=<your-client-id>")
fmt.Fprintln(os.Stderr, "\nYou can find the client_id in the server startup logs.")
os.Exit(1)
}

flagServerURL = flag.String(
"server-url",
"",
"OAuth server URL (default: http://localhost:8080 or SERVER_URL env)",
)
flagClientID = flag.String("client-id", "", "OAuth client ID (required, or set CLIENT_ID env)")
flagClientSecret = flag.String(
"client-secret",
"",
"OAuth client secret (confidential clients only; omit for public/PKCE clients)",
)
flagRedirectURI = flag.String(
"redirect-uri",
"",
"Redirect URI registered with the OAuth server (default: http://localhost:PORT/callback)",
)
flagCallbackPort = flag.Int(
"port",
0,
"Local callback port for browser flow (default: 8888 or CALLBACK_PORT env)",
)
flagScope = flag.String("scope", "", "Space-separated OAuth scopes (default: \"read write\")")
flagTokenFile = flag.String(
"token-file",
"",
"Token storage file (default: .authgate-tokens.json or TOKEN_FILE env)",
)
flagTokenStore = flag.String(
"token-store",
"",
"Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)",
)
flagDevice = flag.Bool(
"device",
false,
"Force Device Code Flow (skip browser detection)",
)
flagVersion = flag.Bool("version", false, "Print version and exit")
var storeErr error
tokenStore, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService)
if storeErr != nil {
fmt.Fprintln(os.Stderr, storeErr)
os.Exit(1)
}
}

func initConfig() {
Expand All @@ -107,27 +116,20 @@ func initConfig() {
}
configInitialized = true

flag.Parse()

// --version prints build version and exits immediately.
if *flagVersion {
fmt.Println(getVersion())
os.Exit(0)
}
// initStoreConfig sets clientID, tokenFile, tokenStoreMode, and tokenStore.
initStoreConfig()

// --device forces Device Code Flow unconditionally.
forceDevice = *flagDevice
forceDevice = flagDevice

serverURL = getConfig(*flagServerURL, "SERVER_URL", "http://localhost:8080")
clientID = getConfig(*flagClientID, "CLIENT_ID", "")
clientSecret = getConfig(*flagClientSecret, "CLIENT_SECRET", "")
scope = getConfig(*flagScope, "SCOPE", "read write")
tokenFile = getConfig(*flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
serverURL = getConfig(flagServerURL, "SERVER_URL", "http://localhost:8080")
clientSecret = getConfig(flagClientSecret, "CLIENT_SECRET", "")
scope = getConfig(flagScope, "SCOPE", "read write")

// Resolve callback port (int flag needs special handling).
portStr := ""
if *flagCallbackPort != 0 {
portStr = strconv.Itoa(*flagCallbackPort)
if flagCallbackPort != 0 {
portStr = strconv.Itoa(flagCallbackPort)
}
portStr = getConfig(portStr, "CALLBACK_PORT", "8888")
if _, err := fmt.Sscanf(portStr, "%d", &callbackPort); err != nil || callbackPort <= 0 {
Expand All @@ -136,7 +138,7 @@ func initConfig() {

// Resolve redirect URI (depends on port, so compute after port is known).
defaultRedirectURI := fmt.Sprintf("http://localhost:%d/callback", callbackPort)
redirectURI = getConfig(*flagRedirectURI, "REDIRECT_URI", defaultRedirectURI)
redirectURI = getConfig(flagRedirectURI, "REDIRECT_URI", defaultRedirectURI)

if err := validateServerURL(serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error: Invalid SERVER_URL: %v\n", err)
Expand All @@ -155,15 +157,6 @@ func initConfig() {
fmt.Fprintln(os.Stderr)
}

if clientID == "" {
fmt.Println("Error: CLIENT_ID not set. Please provide it via:")
fmt.Println(" 1. Command-line flag: -client-id=<your-client-id>")
fmt.Println(" 2. Environment variable: CLIENT_ID=<your-client-id>")
fmt.Println(" 3. .env file: CLIENT_ID=<your-client-id>")
fmt.Println("\nYou can find the client_id in the server startup logs.")
os.Exit(1)
}

if _, err := uuid.Parse(clientID); err != nil {
fmt.Fprintf(
os.Stderr,
Expand All @@ -188,14 +181,6 @@ func initConfig() {
panic(fmt.Sprintf("failed to create retry client: %v", err))
}

// Initialize token store based on mode
tokenStoreMode = getConfig(*flagTokenStore, "TOKEN_STORE", "auto")
var storeErr error
tokenStore, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService)
if storeErr != nil {
fmt.Fprintln(os.Stderr, storeErr)
os.Exit(1)
}
if tokenStoreMode == "auto" {
if ss, ok := tokenStore.(*credstore.SecureStore[credstore.Token]); ok && !ss.UseKeyring() {
fmt.Fprintln(
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.10.2
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
)
Expand All @@ -28,10 +29,12 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
golang.org/x/sync v0.20.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand All @@ -42,6 +43,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
Expand All @@ -56,6 +59,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand All @@ -64,6 +72,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
Expand All @@ -75,5 +84,6 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
54 changes: 47 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,58 @@ import (

retry "github.com/appleboy/go-httpretry"
"github.com/go-authgate/cli/tui"
"github.com/spf13/cobra"
)

// exitCodeError carries a non-zero exit code through Cobra's error chain
// without printing a redundant message (the command already wrote to stderr).
type exitCodeError int

func (e exitCodeError) Error() string { return "" }

func main() {
initConfig()
if err := buildRootCmd().Execute(); err != nil {
var exitErr exitCodeError
if errors.As(err, &exitErr) {
os.Exit(int(exitErr))
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

// Select UI manager based on environment detection
uiManager := tui.SelectManager()
func buildRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "authgate-cli",
Short: "OAuth 2.0 authentication CLI",
Version: getVersion(),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
Comment on lines +31 to +48
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SilenceErrors: true combined with main() exiting on Execute() error without printing it will cause user-facing failures (e.g., unknown flag/invalid args) to exit with code 1 and no message. Either print the returned error in main() (to stderr) or stop silencing errors and let Cobra print them (keeping SilenceUsage: true is fine).

Copilot uses AI. Check for mistakes.
initConfig()
uiManager := tui.SelectManager()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if code := run(ctx, uiManager); code != 0 {
return exitCodeError(code)
}
return nil
},
Comment on lines +48 to +57
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling os.Exit inside Cobra RunE. Exiting here bypasses Cobra's normal error/return flow and makes the command harder to test or reuse (and will skip any future defers added above). Prefer returning an error (or a sentinel error type carrying an exit code) and handle the process exit in main() after Execute() returns.

Copilot uses AI. Check for mistakes.
}
registerFlags(rootCmd)
rootCmd.AddCommand(buildVersionCmd())
rootCmd.AddCommand(buildTokenCmd())
return rootCmd
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
exitCode := run(ctx, uiManager)
stop()
os.Exit(exitCode)
func buildVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(getVersion())
},
}
}

func run(ctx context.Context, ui tui.Manager) int {
Expand Down
Loading
Loading