Skip to content
Draft
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
32 changes: 31 additions & 1 deletion cmd/auth/login/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,22 @@ func RunE(cmd *cobra.Command, args []string) error { //nolint: cyclop

noBrowser, _ := cmd.Flags().GetBool("no-browser")

// Only pass an override when the user actually said something. Left nil,
// DATAROBOT_OAUTH_ENABLED decides, and its default is off.
var oauthOverride *bool

switch {
case cmd.Flags().Changed("oauth"):
oauth, _ := cmd.Flags().GetBool("oauth")
oauthOverride = &oauth
case cmd.Flags().Changed("no-oauth"):
off := false
oauthOverride = &off
}

key, err := auth.RunBrowserLoginWith(cmd.Context(), datarobotHost, auth.LoginOptions{
NoBrowser: noBrowser,
OAuth: oauthOverride,
})
if err != nil {
log.Error(err)
Expand Down Expand Up @@ -119,7 +133,13 @@ This command will:
3. Securely store your API key for future CLI operations.

If the browser cannot be opened, the CLI prints a link to open yourself. Pass
--no-browser to skip the browser launch entirely, which is useful over SSH.`,
--no-browser to skip the browser launch entirely, which is useful over SSH.

Deployments that front their own OAuth2 authorization server can be logged
into with --oauth, which runs a standard authorization-code flow with PKCE
instead of the DataRobot hand-off. The access token then never travels in a
URL. Set DATAROBOT_OAUTH_ENABLED=true to make that the default for a shell;
--no-oauth forces the hand-off back on.`,
SilenceErrors: true,
SilenceUsage: true,
RunE: RunE,
Expand All @@ -129,5 +149,15 @@ If the browser cannot be opened, the CLI prints a link to open yourself. Pass
// per-invocation flag and must never be persisted to drconfig.yaml.
cmd.Flags().Bool("no-browser", false, "print the login link instead of opening a browser")

// Same reasoning as --no-browser: transient, never persisted.
//
// Two separate booleans, because pflag does not synthesise --no- variants
// for a bool flag. Read via Flags().Changed so "unset" stays
// distinguishable from an explicit --no-oauth, which is what lets the flag
// override DATAROBOT_OAUTH_ENABLED in both directions.
cmd.Flags().Bool("oauth", false, "log in with OAuth2 authorization-code + PKCE (default: DATAROBOT_OAUTH_ENABLED)")
Comment thread
elatt marked this conversation as resolved.
cmd.Flags().Bool("no-oauth", false, "force the DataRobot hand-off even if DATAROBOT_OAUTH_ENABLED is set")
cmd.MarkFlagsMutuallyExclusive("oauth", "no-oauth")

return cmd
}
5 changes: 5 additions & 0 deletions cmd/auth/logout/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import (
func RunE(_ *cobra.Command, _ []string) error {
viperx.Set(config.DataRobotAPIKey, "")

// Clearing the access token alone would leave a working refresh token on
// disk — logout has to drop the means of getting a new one too, or it does
// not log anyone out.
auth.ClearOAuthState()

err := auth.WriteConfigFile()
if err != nil {
log.Error(fmt.Errorf("failed to write config: %w", err))
Expand Down
58 changes: 58 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,15 @@ func EnsureAuthenticated(ctx context.Context) bool { //nolint: cyclop
return true
}

// A REJECTED token may be renewable without a browser — the reason
// `offline_access` is requested at login. Only on a judged rejection: an
// unjudged failure (404, 5xx, a network blip) is not evidence of expiry,
// and spending a rotate-on-use refresh token against a server that never
// rejected anything would turn an outage into a forced re-login.
if tokenWasRejected(viperErr) && renewStoredToken(ctx) {
return true
}

skipAuthFlow := false

// Everything this gate prints goes to stderr: PreRunE runs before the command,
Expand Down Expand Up @@ -648,3 +657,52 @@ func GetBaseURLOrAsk() string {

return datarobotHost
}

// renewStoredToken swaps an expired access token for a fresh one and reports
// whether the profile ends up usable. false means "carry on to the interactive
// login", including the ordinary case of nothing to renew with.
func renewStoredToken(ctx context.Context) bool {
if _, err := RefreshAccessToken(ctx); err != nil {
if !errors.Is(err, ErrNoRefreshToken) {
log.Debugf("Could not renew the access token: %v", err)
}

return false
}

// Verify rather than trust: a server handing back a token it will not
// accept would otherwise loop us silently.
if _, err := config.GetAPIKey(ctx); err != nil {
log.Debug("Renewed token did not verify; falling back to interactive login")
ClearOAuthState()

return false
}

if err := WriteConfigFileSilent(); err != nil {
log.Error("Failed to write config file.", "error", err)

return false
}

log.Debug("Renewed the access token with the stored refresh token")

return true
}

// tokenWasRejected reports whether a failure was the server judging the
// credential (401/403) rather than being unable to answer — the same
// distinction fprintServerStatus makes.
func tokenWasRejected(err error) bool {
if err == nil {
return false
}

var statusErr *config.HTTPStatusError
if !errors.As(err, &statusErr) {
return false
}

return statusErr.StatusCode == http.StatusUnauthorized ||
statusErr.StatusCode == http.StatusForbidden
}
Loading
Loading