11package main
22
33import (
4+ "context"
45 "crypto/tls"
56 "errors"
67 "fmt"
@@ -13,6 +14,7 @@ import (
1314 "time"
1415
1516 "github.com/go-authgate/sdk-go/credstore"
17+ "github.com/go-authgate/sdk-go/discovery"
1618
1719 retry "github.com/appleboy/go-httpretry"
1820 "github.com/google/uuid"
@@ -34,17 +36,38 @@ var (
3436 flagTokenFile string
3537 flagTokenStore string
3638 flagDevice bool
39+
40+ flagTokenExchangeTimeout string
41+ flagTokenVerificationTimeout string
42+ flagRefreshTokenTimeout string
43+ flagDeviceCodeRequestTimeout string
44+ flagCallbackTimeout string
45+ flagUserInfoTimeout string
46+ flagMaxResponseBodySize string
3747)
3848
3949const (
40- tokenExchangeTimeout = 10 * time .Second
41- tokenVerificationTimeout = 10 * time .Second
42- refreshTokenTimeout = 10 * time .Second
43- deviceCodeRequestTimeout = 10 * time .Second
44- maxResponseBodySize = 1 * 1024 * 1024 // 1 MB — guards against oversized server responses
45- defaultKeyringService = "authgate-cli"
50+ defaultTokenExchangeTimeout = 10 * time .Second
51+ defaultTokenVerificationTimeout = 10 * time .Second
52+ defaultRefreshTokenTimeout = 10 * time .Second
53+ defaultDeviceCodeRequestTimeout = 10 * time .Second
54+ defaultCallbackTimeout = 2 * time .Minute
55+ defaultUserInfoTimeout = 10 * time .Second
56+ defaultMaxResponseBodySize = 1 * 1024 * 1024 // 1 MB — guards against oversized server responses
57+ defaultKeyringService = "authgate-cli"
4658)
4759
60+ // ResolvedEndpoints holds the absolute URLs for all OAuth endpoints.
61+ // Populated from OIDC Discovery or from hardcoded fallback paths.
62+ type ResolvedEndpoints struct {
63+ AuthorizeURL string
64+ TokenURL string
65+ DeviceAuthorizationURL string
66+ TokenInfoURL string
67+ UserinfoURL string
68+ RevocationURL string
69+ }
70+
4871// AppConfig holds all resolved configuration for the CLI application.
4972type AppConfig struct {
5073 ServerURL string
@@ -57,6 +80,20 @@ type AppConfig struct {
5780 TokenStoreMode string // "auto", "file", or "keyring"
5881 RetryClient * retry.Client
5982 Store credstore.Store [credstore.Token ]
83+
84+ // Endpoints holds the resolved OAuth endpoint URLs.
85+ // Populated by resolveEndpoints after loadConfig.
86+ Endpoints ResolvedEndpoints
87+
88+ // Timeout configuration (resolved from flag → env → default).
89+ // Only populated by loadConfig; zero in loadStoreConfig paths.
90+ TokenExchangeTimeout time.Duration
91+ TokenVerificationTimeout time.Duration
92+ RefreshTokenTimeout time.Duration
93+ DeviceCodeRequestTimeout time.Duration
94+ CallbackTimeout time.Duration
95+ UserInfoTimeout time.Duration
96+ MaxResponseBodySize int64
6097}
6198
6299// IsPublicClient returns true when no client secret is configured —
@@ -85,6 +122,20 @@ func registerFlags(cmd *cobra.Command) {
85122 StringVar (& flagTokenStore , "token-store" , "" , "Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)" )
86123 cmd .PersistentFlags ().
87124 BoolVar (& flagDevice , "device" , false , "Force Device Code Flow (skip browser detection)" )
125+ cmd .PersistentFlags ().
126+ StringVar (& flagTokenExchangeTimeout , "token-exchange-timeout" , "" , "Timeout for token exchange requests (e.g. 10s, 1m)" )
127+ cmd .PersistentFlags ().
128+ StringVar (& flagTokenVerificationTimeout , "token-verification-timeout" , "" , "Timeout for token verification requests (e.g. 10s, 1m)" )
129+ cmd .PersistentFlags ().
130+ StringVar (& flagRefreshTokenTimeout , "refresh-token-timeout" , "" , "Timeout for token refresh requests (e.g. 10s, 1m)" )
131+ cmd .PersistentFlags ().
132+ StringVar (& flagDeviceCodeRequestTimeout , "device-code-request-timeout" , "" , "Timeout for device code requests (e.g. 10s, 1m)" )
133+ cmd .PersistentFlags ().
134+ StringVar (& flagCallbackTimeout , "callback-timeout" , "" , "Timeout waiting for browser callback (e.g. 2m, 5m)" )
135+ cmd .PersistentFlags ().
136+ StringVar (& flagUserInfoTimeout , "userinfo-timeout" , "" , "Timeout for UserInfo requests (e.g. 10s, 1m)" )
137+ cmd .PersistentFlags ().
138+ StringVar (& flagMaxResponseBodySize , "max-response-body-size" , "" , "Maximum response body size in bytes (e.g. 1048576)" )
88139}
89140
90141// loadStoreConfig initialises only the token store and client ID — the minimum
@@ -180,6 +231,25 @@ func loadConfig() *AppConfig {
180231 panic (fmt .Sprintf ("failed to create retry client: %v" , err ))
181232 }
182233
234+ // Resolve timeout configuration.
235+ cfg .TokenExchangeTimeout = getDurationConfig (
236+ flagTokenExchangeTimeout , "TOKEN_EXCHANGE_TIMEOUT" , defaultTokenExchangeTimeout )
237+ cfg .TokenVerificationTimeout = getDurationConfig (
238+ flagTokenVerificationTimeout , "TOKEN_VERIFICATION_TIMEOUT" , defaultTokenVerificationTimeout )
239+ cfg .RefreshTokenTimeout = getDurationConfig (
240+ flagRefreshTokenTimeout , "REFRESH_TOKEN_TIMEOUT" , defaultRefreshTokenTimeout )
241+ cfg .DeviceCodeRequestTimeout = getDurationConfig (
242+ flagDeviceCodeRequestTimeout ,
243+ "DEVICE_CODE_REQUEST_TIMEOUT" ,
244+ defaultDeviceCodeRequestTimeout ,
245+ )
246+ cfg .CallbackTimeout = getDurationConfig (
247+ flagCallbackTimeout , "CALLBACK_TIMEOUT" , defaultCallbackTimeout )
248+ cfg .UserInfoTimeout = getDurationConfig (
249+ flagUserInfoTimeout , "USERINFO_TIMEOUT" , defaultUserInfoTimeout )
250+ cfg .MaxResponseBodySize = getInt64Config (
251+ flagMaxResponseBodySize , "MAX_RESPONSE_BODY_SIZE" , defaultMaxResponseBodySize )
252+
183253 if cfg .TokenStoreMode == "auto" {
184254 if ss , ok := cfg .Store .(* credstore.SecureStore [credstore.Token ]); ok && ! ss .UseKeyring () {
185255 fmt .Fprintln (
@@ -239,6 +309,92 @@ func validateServerURL(rawURL string) error {
239309 return nil
240310}
241311
312+ // defaultEndpoints returns hardcoded endpoint paths appended to serverURL.
313+ // Used as fallback when OIDC Discovery is unavailable.
314+ func defaultEndpoints (serverURL string ) ResolvedEndpoints {
315+ return ResolvedEndpoints {
316+ AuthorizeURL : serverURL + "/oauth/authorize" ,
317+ TokenURL : serverURL + "/oauth/token" ,
318+ DeviceAuthorizationURL : serverURL + "/oauth/device/code" ,
319+ TokenInfoURL : serverURL + "/oauth/tokeninfo" ,
320+ UserinfoURL : serverURL + "/oauth/userinfo" ,
321+ RevocationURL : serverURL + "/oauth/revoke" ,
322+ }
323+ }
324+
325+ // resolveEndpoints attempts OIDC Discovery and falls back to hardcoded paths.
326+ func resolveEndpoints (ctx context.Context , cfg * AppConfig ) {
327+ disco , err := discovery .NewClient (
328+ cfg .ServerURL ,
329+ discovery .WithHTTPClient (cfg .RetryClient ),
330+ )
331+ if err != nil {
332+ fmt .Fprintf (os .Stderr ,
333+ "WARNING: OIDC Discovery init failed: %v (using default endpoints)\n " , err )
334+ cfg .Endpoints = defaultEndpoints (cfg .ServerURL )
335+ return
336+ }
337+
338+ fetchCtx , cancel := context .WithTimeout (ctx , 10 * time .Second )
339+ defer cancel ()
340+
341+ meta , err := disco .Fetch (fetchCtx )
342+ if err != nil {
343+ fmt .Fprintf (os .Stderr ,
344+ "WARNING: OIDC Discovery fetch failed: %v (using default endpoints)\n " , err )
345+ cfg .Endpoints = defaultEndpoints (cfg .ServerURL )
346+ return
347+ }
348+
349+ ep := meta .Endpoints ()
350+ cfg .Endpoints = ResolvedEndpoints {
351+ AuthorizeURL : ep .AuthorizeURL ,
352+ TokenURL : ep .TokenURL ,
353+ DeviceAuthorizationURL : ep .DeviceAuthorizationURL ,
354+ TokenInfoURL : ep .TokenInfoURL ,
355+ UserinfoURL : ep .UserinfoURL ,
356+ RevocationURL : ep .RevocationURL ,
357+ }
358+ }
359+
360+ // getDurationConfig resolves a time.Duration from flag → env → default.
361+ // The value is parsed with time.ParseDuration (e.g. "10s", "2m", "1m30s").
362+ // On parse error or non-positive value, it falls back to the default and prints a warning.
363+ func getDurationConfig (flagValue , envKey string , defaultValue time.Duration ) time.Duration {
364+ raw := getConfig (flagValue , envKey , "" )
365+ if raw == "" {
366+ return defaultValue
367+ }
368+ d , err := time .ParseDuration (raw )
369+ if err != nil {
370+ fmt .Fprintf (os .Stderr , "WARNING: invalid duration %q for %s, using default %s\n " ,
371+ raw , envKey , defaultValue )
372+ return defaultValue
373+ }
374+ if d <= 0 {
375+ fmt .Fprintf (os .Stderr , "WARNING: %s must be positive, got %s, using default %s\n " ,
376+ envKey , d , defaultValue )
377+ return defaultValue
378+ }
379+ return d
380+ }
381+
382+ // getInt64Config resolves an int64 from flag → env → default.
383+ // On parse error or non-positive value, it falls back to the default and prints a warning.
384+ func getInt64Config (flagValue , envKey string , defaultValue int64 ) int64 {
385+ raw := getConfig (flagValue , envKey , "" )
386+ if raw == "" {
387+ return defaultValue
388+ }
389+ v , err := strconv .ParseInt (raw , 10 , 64 )
390+ if err != nil || v <= 0 {
391+ fmt .Fprintf (os .Stderr , "WARNING: invalid value %q for %s, using default %d\n " ,
392+ raw , envKey , defaultValue )
393+ return defaultValue
394+ }
395+ return v
396+ }
397+
242398// getVersion returns the build version, preferring the ldflags-injected value
243399// and falling back to debug.ReadBuildInfo().
244400func getVersion () string {
0 commit comments