diff --git a/go.mod b/go.mod index e8ff713e..3c999865 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.25.7 require ( github.com/99designs/keyring v1.2.2 github.com/AlecAivazis/survey/v2 v2.3.7 - github.com/aws/aws-sdk-go-v2 v1.26.1 + github.com/aws/aws-sdk-go-v2 v1.41.2 github.com/aws/aws-sdk-go-v2/config v1.27.11 github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/errors v0.9.1 @@ -62,12 +62,12 @@ require ( github.com/BurntSushi/toml v1.3.2 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/service/iam v1.28.7 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect - github.com/aws/smithy-go v1.20.2 + github.com/aws/smithy-go v1.24.1 github.com/common-fate/awsconfigfile v0.10.0 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.2 // indirect diff --git a/go.sum b/go.sum index 7236b3d2..3751100a 100644 --- a/go.sum +++ b/go.sum @@ -16,18 +16,18 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= -github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= -github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE= github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/service/iam v1.28.7 h1:FKPRDYZOO0Eur19vWUL1B40Op0j89KQj3kARjrszMK8= @@ -38,12 +38,12 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/g github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= -github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= -github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= @@ -63,8 +63,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= -github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= diff --git a/pkg/cfaws/assumer_aws_sso.go b/pkg/cfaws/assumer_aws_sso.go index 35a065aa..d3fe645c 100644 --- a/pkg/cfaws/assumer_aws_sso.go +++ b/pkg/cfaws/assumer_aws_sso.go @@ -192,7 +192,19 @@ func (c *Profile) SSOLogin(ctx context.Context, configOpts ConfigOpts) (aws.Cred if cachedToken == nil && plainTextToken == nil { newCfg := aws.NewConfig() newCfg.Region = rootProfile.SSORegion() - newSSOToken, err := idclogin.Login(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes()) + + var newSSOToken *securestorage.SSOToken + var err error + + // Use authorization code flow with PKCE when an sso_session is configured, + // unless the user has explicitly requested device code flow or we're + // in a headless environment where the localhost redirect won't work. + useDeviceCode := configOpts.UseDeviceCode || idclogin.IsHeadlessEnvironment() + if c.AWSConfig.SSOSessionName != "" && !useDeviceCode { + newSSOToken, err = idclogin.LoginWithAuthorizationCode(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes()) + } else { + newSSOToken, err = idclogin.Login(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes()) + } if err != nil { return aws.Credentials{}, err } diff --git a/pkg/cfaws/profiles.go b/pkg/cfaws/profiles.go index 140db9e9..baab7afb 100644 --- a/pkg/cfaws/profiles.go +++ b/pkg/cfaws/profiles.go @@ -27,6 +27,7 @@ type ConfigOpts struct { ShouldRetryAssuming *bool MFATokenCode string DisableCache bool + UseDeviceCode bool } type Profile struct { diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index 0af49e28..4c064b29 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -245,6 +245,7 @@ var LoginCommand = cli.Command{ &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, &cli.StringFlag{Name: "sso-start-url", Usage: "Specify the SSO start url"}, &cli.StringSliceFlag{Name: "sso-scope", Usage: "Specify the SSO scopes"}, + &cli.BoolFlag{Name: "use-device-code", Usage: "Use device code flow instead of authorization code with PKCE"}, }, Action: func(c *cli.Context) error { ctx := c.Context @@ -297,7 +298,15 @@ var LoginCommand = cli.Command{ secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage() - newSSOToken, err := idclogin.Login(ctx, *cfg, ssoStartUrl, ssoScopes) + var newSSOToken *securestorage.SSOToken + var err error + + useDeviceCode := c.Bool("use-device-code") || idclogin.IsHeadlessEnvironment() + if useDeviceCode { + newSSOToken, err = idclogin.Login(ctx, *cfg, ssoStartUrl, ssoScopes) + } else { + newSSOToken, err = idclogin.LoginWithAuthorizationCode(ctx, *cfg, ssoStartUrl, ssoScopes) + } if err != nil { return err } @@ -347,8 +356,12 @@ func (s AWSSSOSource) GetProfiles(ctx context.Context) ([]awsconfigfile.SSOProfi } if ssoTokenFromSecureCache == nil && ssoTokenFromPlainText == nil { - // otherwise, login with SSO - ssoTokenFromSecureCache, err = idclogin.Login(ctx, cfg, s.StartURL, s.SSOScopes) + // Login with SSO, using authorization code flow by default unless headless + if idclogin.IsHeadlessEnvironment() { + ssoTokenFromSecureCache, err = idclogin.Login(ctx, cfg, s.StartURL, s.SSOScopes) + } else { + ssoTokenFromSecureCache, err = idclogin.LoginWithAuthorizationCode(ctx, cfg, s.StartURL, s.SSOScopes) + } if err != nil { return nil, err } diff --git a/pkg/idclogin/authcode.go b/pkg/idclogin/authcode.go new file mode 100644 index 00000000..e5705a5d --- /dev/null +++ b/pkg/idclogin/authcode.go @@ -0,0 +1,304 @@ +package idclogin + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssooidc" + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/securestorage" + "github.com/google/uuid" +) + +// authorizationCallbackTimeout is the maximum time to wait for the user to +// complete browser-based authentication before giving up. +const authorizationCallbackTimeout = 5 * time.Minute + +// LoginWithAuthorizationCode performs an Authorization Code Grant with PKCE flow +// to retrieve an SSO token. This provides a smoother UX than the device code flow +// by skipping the manual code entry step. +func LoginWithAuthorizationCode(ctx context.Context, cfg aws.Config, startUrl string, scopes []string) (*securestorage.SSOToken, error) { + if cfg.Region == "" { + return nil, errors.New("AWS region is required for authorization code flow") + } + + ssooidcClient := ssooidc.NewFromConfig(cfg) + + // The authorization code flow uses "sso:account:access" as the default scope, + // which is the modern scope for IAM Identity Center. This differs from the + // device code flow's legacy "sso-portal:*" default. + if len(scopes) == 0 { + scopes = []string{"sso:account:access"} + } + + // Bind the listener first to reserve a port for the redirect URI. + // We defer starting the HTTP server until after RegisterClient succeeds. + callbackResult := make(chan authCallbackResult, 1) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to start local OAuth callback server: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth/callback", port) + + state := uuid.New().String() + + // Register client with authorization_code grant type. + // + // The redirect URI for registration uses the portless form. Per RFC 8252 + // Section 7.3, authorization servers MUST allow any port to be specified for + // loopback redirect URIs. AWS IAM Identity Center implements this exemption: + // the portless URI is registered, but the actual redirect uses a port-specific URI. + client, err := ssooidcClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{ + ClientName: aws.String("Granted CLI"), + ClientType: aws.String("public"), + GrantTypes: []string{"authorization_code", "refresh_token"}, + RedirectUris: []string{"http://127.0.0.1/oauth/callback"}, + IssuerUrl: aws.String(startUrl), + Scopes: scopes, + }) + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("failed to register OIDC client: %w", err) + } + + // Now that registration succeeded, start the HTTP server to receive the callback. + srv := &http.Server{ + Handler: newCallbackHandler(state, callbackResult), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 30 * time.Second, + } + go func() { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + clio.Debugf("OAuth callback server error: %s", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + // Determine the authorization endpoint. The RegisterClient API may return it, + // but many regions don't include it in the response. Fall back to the standard + // regional endpoint pattern used by the AWS CLI. + authorizationEndpoint := fmt.Sprintf("https://oidc.%s.amazonaws.com/authorize", cfg.Region) + if client.AuthorizationEndpoint != nil && *client.AuthorizationEndpoint != "" { + authorizationEndpoint = *client.AuthorizationEndpoint + } + + // Generate PKCE code verifier and challenge + codeVerifier, err := generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE code verifier: %w", err) + } + codeChallenge := computeCodeChallenge(codeVerifier) + + // Construct the authorization URL + authorizeURL, err := buildAuthorizeURL(authorizationEndpoint, *client.ClientId, redirectURI, state, codeChallenge, scopes) + if err != nil { + return nil, fmt.Errorf("failed to build authorize URL: %w", err) + } + + // Open browser with fallback message + if err := OpenBrowserWithFallbackMessage(authorizeURL); err != nil { + return nil, err + } + + clio.Info("Awaiting AWS authentication in the browser") + clio.Info("You will be prompted to authenticate and approve access") + + // Wait for the callback + var result authCallbackResult + select { + case result = <-callbackResult: + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(authorizationCallbackTimeout): + return nil, errors.New("timed out waiting for authorization callback") + } + + if result.err != nil { + return nil, fmt.Errorf("authorization failed: %w", result.err) + } + + // Exchange the authorization code for tokens + token, err := ssooidcClient.CreateToken(ctx, &ssooidc.CreateTokenInput{ + ClientId: client.ClientId, + ClientSecret: client.ClientSecret, + GrantType: aws.String("authorization_code"), + Code: aws.String(result.code), + CodeVerifier: aws.String(codeVerifier), + RedirectUri: aws.String(redirectURI), + }) + if err != nil { + return nil, fmt.Errorf("failed to exchange authorization code for token: %w", err) + } + + ssoToken := securestorage.SSOToken{ + AccessToken: *token.AccessToken, + Expiry: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second), + ClientID: *client.ClientId, + ClientSecret: *client.ClientSecret, + RegistrationExpiresAt: time.Unix(client.ClientSecretExpiresAt, 0), + RefreshToken: token.RefreshToken, + Region: cfg.Region, + } + + return &ssoToken, nil +} + +type authCallbackResult struct { + code string + err error +} + +type callbackPageData struct { + Error string + Description string +} + +var callbackErrorTmpl = template.Must(template.New("error").Parse(` + +
Error: {{.Error}}
+{{.Description}}
+Please close this window and try again.
+You have successfully authenticated with AWS IAM Identity Center.
+You can close this window and return to your terminal.
+