Skip to content

Commit

Permalink
Device Auth Flow (#1166)
Browse files Browse the repository at this point in the history
* Fixed device auth login script

* Fixing device auth login script

* Removed hardcoded ttl from the device auth script

* Improving client cert retrieval in the device auth script

* Added Device auth flow flag to login command

* Refactored device auth flow

* Refactored login tests & added cases for device auth flow

* Reordered imports & fixed timeouts in auth tests

* Fixed context in auth tests

* Fixed tainted URLs in device auth flow

* Migrated Device auth away from deprecated Dex endpoint & fixed tests

* Updated changelog

* Improved device auth error handling

* Cleaned up

* Improved device-auth flag description & cleaned up

* Improvements in login command tests

* Added a note about ignoring the device-auth flag

* Improved warning message when device-auth flag is ignored

* Added support for non-interactive device auth login

* Fixed tests

* Reordered imports

* Improved device auth flow
  • Loading branch information
vvondruska authored Nov 28, 2023
1 parent e542b76 commit e185a88
Show file tree
Hide file tree
Showing 15 changed files with 847 additions and 232 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project's packages adheres to [Semantic Versioning](http://semver.org/s

## [Unreleased]

### Added

- Add support for device authentication flow in the `login` command and a new `--device-auth` flag to activate it.

## [2.47.1] - 2023-11-15

### Changed
Expand Down
9 changes: 9 additions & 0 deletions cmd/login/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,12 @@ var clusterAPINotKnownError = &microerror.Error{
func IsClusterAPINotKnown(err error) bool {
return microerror.Cause(err) == clusterAPINotKnownError
}

var deviceAuthError = &microerror.Error{
Kind: "deviceAuthError",
}

// IsDeviceAuthError asserts deviceAuthError.
func IsDeviceAuthError(err error) bool {
return microerror.Cause(err) == deviceAuthError
}
6 changes: 6 additions & 0 deletions cmd/login/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const (
flagAwsProfile = "aws-profile"

flagLoginTimeout = "login-timeout"

flagDeviceAuth = "device-auth"
)

type flag struct {
Expand All @@ -56,6 +58,8 @@ type flag struct {
AWSProfile string

LoginTimeout time.Duration

DeviceAuth bool
}

func (f *flag) Init(cmd *cobra.Command) {
Expand All @@ -82,6 +86,8 @@ func (f *flag) Init(cmd *cobra.Command) {

cmd.Flags().DurationVar(&f.LoginTimeout, flagLoginTimeout, 60*time.Second, "Duration for which kubectl gs will wait for the OIDC login to complete. Once the timeout is reached, OIDC login will fail.")

cmd.Flags().BoolVar(&f.DeviceAuth, flagDeviceAuth, false, "Use device authentication flow to log in")

_ = cmd.Flags().MarkHidden(flagWCInsecureNamespace)
_ = cmd.Flags().MarkHidden("namespace")
}
Expand Down
49 changes: 41 additions & 8 deletions cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/fatih/color"
"github.com/giantswarm/microerror"
"k8s.io/client-go/tools/clientcmd"

"github.com/giantswarm/kubectl-gs/v2/pkg/installation"
"github.com/giantswarm/kubectl-gs/v2/pkg/kubeconfig"
Expand Down Expand Up @@ -39,12 +41,12 @@ func (r *runner) loginWithKubeContextName(ctx context.Context, contextName strin
return microerror.Mask(err)
}

if newLoginRequired || r.loginOptions.selfContained {
config, err := k8sConfigAccess.GetStartingConfig()
if err != nil {
return microerror.Mask(err)
}
config, err := k8sConfigAccess.GetStartingConfig()
if err != nil {
return microerror.Mask(err)
}

if newLoginRequired || r.loginOptions.selfContained {
authType := kubeconfig.GetAuthType(config, contextName)
if authType == kubeconfig.AuthTypeAuthProvider {
// If we get here, we are sure that the kubeconfig context exists.
Expand All @@ -59,6 +61,15 @@ func (r *runner) loginWithKubeContextName(ctx context.Context, contextName strin
return nil
}

if r.flag.DeviceAuth {
fmt.Fprintf(r.stdout, color.YellowString("A valid `%s` context already exists, there is no need to log in again, ignoring the `device-flow` flag.\n\n"), contextName)
if clusterServer, exists := kubeconfig.GetClusterServer(config, contextName); exists {
fmt.Fprintf(r.stdout, "Run kubectl gs login with `%s` instead of the context name to force the re-login.\n", clusterServer)
} else {
fmt.Fprintln(r.stdout, "Run kubectl gs login with the API URL to force the re-login.")
}
}

if contextAlreadySelected {
fmt.Fprintf(r.stdout, "Context '%s' is already selected.\n", contextName)
} else if !r.loginOptions.isWC && r.loginOptions.switchToContext {
Expand Down Expand Up @@ -138,15 +149,20 @@ func (r *runner) loginWithInstallation(ctx context.Context, tokenOverride string
token: tokenOverride,
}
} else {
authResult, err = handleOIDC(ctx, r.stdout, r.stderr, i, r.flag.ConnectorID, r.flag.ClusterAdmin, r.flag.CallbackServerHost, r.flag.CallbackServerPort, r.flag.LoginTimeout)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || IsAuthResponseTimedOut(err) {
contextName := kubeconfig.GenerateKubeContextName(i.Codename)
if r.flag.DeviceAuth || r.isDeviceAuthContext(k8sConfigAccess, contextName) {
authResult, err = handleDeviceFlowOIDC(r.stdout, i)
} else {
authResult, err = handleOIDC(ctx, r.stdout, r.stderr, i, r.flag.ConnectorID, r.flag.ClusterAdmin, r.flag.CallbackServerHost, r.flag.CallbackServerPort, r.flag.LoginTimeout)
if err != nil && errors.Is(err, context.DeadlineExceeded) || IsAuthResponseTimedOut(err) {
fmt.Fprintf(r.stderr, "\nYour authentication flow timed out after %s. Please execute the same command again.\n", r.flag.LoginTimeout.String())
fmt.Fprintf(r.stderr, "You can use the --login-timeout flag to configure a longer timeout interval, for example --login-timeout=%.0fs.\n", 2*r.flag.LoginTimeout.Seconds())
if errors.Is(err, context.DeadlineExceeded) {
return microerror.Maskf(authResponseTimedOutError, "failed to get an authentication response on time")
}
}
}
if err != nil {
return microerror.Mask(err)
}

Expand All @@ -172,3 +188,20 @@ func (r *runner) loginWithInstallation(ctx context.Context, tokenOverride string
}
return nil
}

func (r *runner) isDeviceAuthContext(k8sConfigAccess clientcmd.ConfigAccess, contextName string) bool {
config, err := k8sConfigAccess.GetStartingConfig()
if err != nil {
return false
}

if originContext, ok := config.Contexts[contextName]; ok {
return isDeviceAuthInfo(originContext.AuthInfo)
}

return false
}

func isDeviceAuthInfo(authInfo string) bool {
return strings.HasSuffix(authInfo, "-device")
}
24 changes: 24 additions & 0 deletions cmd/login/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ func handleOIDC(ctx context.Context, out io.Writer, errOut io.Writer, i *install
return authResult, nil
}

// handleDeviceFlowOIDC executes the OIDC device authentication flow against an installation's authentication provider.
func handleDeviceFlowOIDC(out io.Writer, i *installation.Installation) (authInfo, error) {
auther := oidc.NewDeviceAuthenticator(clientID, i)

deviceCodeData, err := auther.LoadDeviceCode()
if err != nil {
return authInfo{}, microerror.Mask(err)
}

_, _ = fmt.Fprintf(out, "Open this URL in the browser to log in:\n%s\n\nThe process will continue automatically once the in-browser login is completed\n", deviceCodeData.VerificationUriComplete)

deviceTokenData, userName, err := auther.LoadDeviceToken(deviceCodeData)
if err != nil {
return authInfo{}, microerror.Maskf(deviceAuthError, err.Error())
}

return authInfo{
username: fmt.Sprintf("%s-device", userName),
token: deviceTokenData.IdToken,
refreshToken: deviceTokenData.RefreshToken,
clientID: clientID,
}, nil
}

// handleOIDCCallback is the callback executed after the authentication response was
// received from the authentication provider.
func handleOIDCCallback(ctx context.Context, a *oidc.Authenticator) func(w http.ResponseWriter, r *http.Request) (interface{}, error) {
Expand Down
7 changes: 4 additions & 3 deletions cmd/login/runner_config_modify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"sigs.k8s.io/cluster-api/api/v1beta1"

"github.com/giantswarm/kubectl-gs/v2/pkg/commonconfig"
testoidc "github.com/giantswarm/kubectl-gs/v2/test/oidc"
)

func TestKubeConfigModification(t *testing.T) {
Expand Down Expand Up @@ -431,9 +432,9 @@ func mockKubernetesAndAuthServer(org securityv1alpha.Organization, wc v1beta1.Cl
routeAuth := func(r *http.Request) (string, string) {
switch r.URL.Path {
case "/token":
return "text/plain", getToken(idToken, refreshToken)
return "text/plain", testoidc.GetToken(idToken, refreshToken)
case "/.well-known/openid-configuration":
return "application/json", strings.ReplaceAll(getIssuerData(), "ISSUER", issuer)
return "application/json", strings.ReplaceAll(testoidc.GetIssuerData(), "ISSUER", issuer)
}
return "", ""
}
Expand Down Expand Up @@ -466,7 +467,7 @@ func mockKubernetesAndAuthServer(org securityv1alpha.Organization, wc v1beta1.Cl
}

func createCertificate() (certPem []byte, keyPem []byte, err error) {
key, err := getKey()
key, err := testoidc.GetKey()
if err != nil {
return
}
Expand Down
Loading

0 comments on commit e185a88

Please sign in to comment.