Skip to content

Commit 25a104c

Browse files
dgilmanuniclaude
andauthored
feat: add OAuth Authorization Code + PKCE flow for SSO login (#923)
* feat: add OAuth Authorization Code + PKCE flow for SSO login Replace the device code flow as the default SSO login method with the Authorization Code Grant + PKCE flow, matching the AWS CLI's Nov 2024 update. This provides a smoother UX by skipping manual code entry — the browser redirects back automatically via a temporary localhost callback server. The device code flow is preserved as a fallback via --use-device-code flag or automatic headless environment detection (SSH, Docker, CI). Key changes: - New LoginWithAuthorizationCode function implementing RFC 7636 PKCE - Extracted browser-opening logic into reusable browser.go module - Headless environment detection for automatic flow selection - Upgraded ssooidc SDK to v1.35.15 for authorization_code grant support - Security headers (CSP, X-Content-Type-Options, X-Frame-Options) on callback pages - Comprehensive tests including RFC 7636 test vectors and XSS prevention Closes #779 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: make authorization code + PKCE flow opt-in instead of default Device code flow remains the default SSO login method. The PKCE authorization code flow is now opt-in via --use-authorization-code flag or UseAuthorizationCode config setting, reducing risk of breaking existing users behind proxies or restricted networks. - Add UseAuthorizationCode to Config and ConfigOpts structs - Add --use-authorization-code flag to login, assume, generate, populate - Add --use-device-code flag to generate and populate commands - Thread config through all ConfigOpts construction sites - Log warning when headless detection overrides PKCE preference - Remove SSOSessionName as implicit PKCE trigger in assume path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8844072 commit 25a104c

File tree

16 files changed

+727
-103
lines changed

16 files changed

+727
-103
lines changed

go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ go 1.25.7
55
require (
66
github.com/99designs/keyring v1.2.2
77
github.com/AlecAivazis/survey/v2 v2.3.7
8-
github.com/aws/aws-sdk-go-v2 v1.26.1
8+
github.com/aws/aws-sdk-go-v2 v1.41.2
99
github.com/aws/aws-sdk-go-v2/config v1.27.11
1010
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5
11-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4
11+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15
1212
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6
1313
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
1414
github.com/pkg/errors v0.9.1
@@ -62,12 +62,12 @@ require (
6262
github.com/BurntSushi/toml v1.3.2
6363
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
6464
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect
65-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
66-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
65+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
66+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
6767
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
6868
github.com/aws/aws-sdk-go-v2/service/iam v1.28.7
6969
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
70-
github.com/aws/smithy-go v1.20.2
70+
github.com/aws/smithy-go v1.24.1
7171
github.com/common-fate/awsconfigfile v0.10.0
7272
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
7373
github.com/danieljoos/wincred v1.1.2 // indirect

go.sum

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n
1616
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
1717
github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
1818
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
19-
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
20-
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
19+
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
20+
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
2121
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
2222
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
2323
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
2424
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
2525
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
2626
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
27-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
28-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
29-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
30-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
27+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
28+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
29+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
30+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
3131
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
3232
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
3333
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
3838
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
3939
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
4040
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
41-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
42-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
41+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
42+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
4343
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
4444
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
45-
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
46-
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
45+
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
46+
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
4747
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
4848
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
4949
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
6363
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6464
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
6565
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
66-
github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY=
67-
github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
6866
github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA=
6967
github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
7068
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=

pkg/assume/assume.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ func AssumeCommand(c *cli.Context) error {
306306
return err
307307
}
308308

309+
configOpts.UseAuthorizationCode = assumeFlags.Bool("use-authorization-code") || cfg.UseAuthorizationCode
310+
309311
// if getConsoleURL is true, we'll use the AWS federated login to retrieve a URL to access the console.
310312
// depending on how Granted is configured, this is then printed to the terminal or a browser is launched at the URL automatically.
311313
getConsoleURL := !assumeFlags.Bool("env") && ((assumeFlags.Bool("console") || assumeFlags.String("console-destination") != "") || assumeFlags.Bool("active-role") || assumeFlags.String("service") != "" || assumeFlags.Bool("url"))

pkg/assume/entrypoint.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func GlobalFlags() []cli.Flag {
5959
&cli.StringSliceFlag{Name: "browser-launch-template-arg", Usage: "Additional arguments to provide to the browser launch template command in key=value format, e.g. '--browser-launch-template-arg foo=bar"},
6060
&cli.BoolFlag{Name: "skip-profile-registry-sync", Usage: "You can use this to skip the automated profile registry sync process."},
6161
&cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"},
62+
&cli.BoolFlag{Name: "use-authorization-code", Usage: "Use authorization code flow with PKCE for SSO login"},
6263
}
6364
}
6465

pkg/cfaws/assumer_aws_sso.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,21 @@ func (c *Profile) SSOLogin(ctx context.Context, configOpts ConfigOpts) (aws.Cred
192192
if cachedToken == nil && plainTextToken == nil {
193193
newCfg := aws.NewConfig()
194194
newCfg.Region = rootProfile.SSORegion()
195-
newSSOToken, err := idclogin.Login(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes(), configOpts.SSOBrowserProfile)
195+
196+
var newSSOToken *securestorage.SSOToken
197+
var err error
198+
199+
// Use device code by default. Authorization code with PKCE is used only
200+
// when explicitly opted in via flag or config, and not in headless environments.
201+
useDeviceCode := configOpts.UseDeviceCode || idclogin.IsHeadlessEnvironment()
202+
if configOpts.UseAuthorizationCode && useDeviceCode && idclogin.IsHeadlessEnvironment() {
203+
clio.Warn("Headless environment detected, falling back to device code flow instead of authorization code")
204+
}
205+
if configOpts.UseAuthorizationCode && !useDeviceCode {
206+
newSSOToken, err = idclogin.LoginWithAuthorizationCode(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes(), configOpts.SSOBrowserProfile)
207+
} else {
208+
newSSOToken, err = idclogin.Login(ctx, *newCfg, rootProfile.SSOStartURL(), rootProfile.SSOScopes(), configOpts.SSOBrowserProfile)
209+
}
196210
if err != nil {
197211
return aws.Credentials{}, err
198212
}

pkg/cfaws/creds.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func GetAWSCredentials(ctx context.Context) (*aws.Credentials, error) {
8282
duration = *profile.AWSConfig.RoleDurationSeconds
8383
}
8484

85-
credentials, err := profile.AssumeTerminal(ctx, ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: true})
85+
credentials, err := profile.AssumeTerminal(ctx, ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: true, UseAuthorizationCode: cfg.UseAuthorizationCode})
8686
if err != nil {
8787
return nil, err
8888
}

pkg/cfaws/profiles.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type ConfigOpts struct {
2828
MFATokenCode string
2929
DisableCache bool
3030
SSOBrowserProfile string
31+
UseDeviceCode bool
32+
UseAuthorizationCode bool
3133
}
3234

3335
type Profile struct {

pkg/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ type Config struct {
9191
// will cause Granted to hang during the login process.
9292
CredentialProcessAutoLogin bool `toml:",omitempty"`
9393

94+
// UseAuthorizationCode, if 'true', will use the OAuth Authorization Code
95+
// with PKCE flow for SSO login instead of the default device code flow.
96+
//
97+
// This provides a smoother login experience by skipping manual code entry,
98+
// but requires a local browser that can redirect to 127.0.0.1.
99+
// Do not set this to 'true' on headless systems.
100+
UseAuthorizationCode bool `toml:",omitempty"`
101+
94102
SSO map[string]AWSSSOConfiguration `toml:",omitempty"`
95103
}
96104

pkg/granted/credential_process.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ var CredentialProcess = cli.Command{
9090
duration = *profile.AWSConfig.RoleDurationSeconds
9191
}
9292

93-
credentials, err := profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin})
93+
credentials, err := profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin, UseAuthorizationCode: cfg.UseAuthorizationCode})
9494
if err != nil {
9595
return err
9696
}

pkg/granted/doctor/doctor.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,14 @@ func (d *GrantedDoctor) CheckAllAWSKeychainTokens(ctx context.Context) error {
220220
func (d *GrantedDoctor) CheckValidCredentials(ctx context.Context, token cfaws.SSOPlainTextOut) error {
221221
secureSSOTokenStorage := securestorage.NewSecureSSOTokenStorage()
222222

223+
gCfg, err := grantedConfig.Load()
224+
if err != nil {
225+
return err
226+
}
227+
223228
cfg := aws.NewConfig()
224229
cfg.Region = d.Profile.SSORegion()
225-
creds, err := d.Profile.SSOLoginWithToken(ctx, cfg, &token.AccessToken, secureSSOTokenStorage, cfaws.ConfigOpts{})
230+
creds, err := d.Profile.SSOLoginWithToken(ctx, cfg, &token.AccessToken, secureSSOTokenStorage, cfaws.ConfigOpts{UseAuthorizationCode: gCfg.UseAuthorizationCode})
226231
if _, ok := err.(cfaws.NoAccessError); ok {
227232
clio.Info("No access to current profile, skipping...\n")
228233
return nil

0 commit comments

Comments
 (0)