Skip to content

feat: Implement OAuth2 PKCE in SSORoleCredentialsProvider#359

Open
patrickod wants to merge 1 commit into
ByteNess:mainfrom
patrickod:patrickod/pkce-byteness-7.10.5
Open

feat: Implement OAuth2 PKCE in SSORoleCredentialsProvider#359
patrickod wants to merge 1 commit into
ByteNess:mainfrom
patrickod:patrickod/pkce-byteness-7.10.5

Conversation

@patrickod

Copy link
Copy Markdown

Ported from tailscale/aws-vault@949040e

Inspired by AWS CLI this PR adds support for OAuth2 "authorization code" grant flow accompanied by Proof Key for Code Exchange (PKCE) by OAuth Public Clients and makes it the default.

In the "device code" flow the user is entirely responsible for matching the one time password displayed by their terminal and browser. This is error prone at best and a one click penalty for every login at worst.

In the "authorization code" flow we create an ephemeral HTTP server to host an OAuth2 callback on 127.0.0.1. Users are redirected here after confirming their login attempt and the code comparison step is performed for them. AWS enforces the use of 127.0.0.1 as a callback destination host so a remote attacker who successfully convinces a user to confirm their malicious login prompt would not be able to access the resulting credential as the user would be redirected to their own localhost and not an attacker controlled destination.

If we determine that we are not able to open a browser (either from the user specifying --stdout or observing that we are executing in an SSH session) we revert to the previous "device code" flow and print the URLs for copy/pasting into browsers.

@patrickod patrickod requested a review from mbevc1 as a code owner May 5, 2026 20:43
@mbevc1 mbevc1 changed the title vault: Implement OAuth2 PKCE in SSORoleCredentialsProvider feat: Implement OAuth2 PKCE in SSORoleCredentialsProvider May 5, 2026
@github-actions github-actions Bot added the feat label May 5, 2026
@patrickod patrickod force-pushed the patrickod/pkce-byteness-7.10.5 branch from 11c7540 to bf36047 Compare May 5, 2026 21:27

@mbevc1 mbevc1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @patrickod and looks great! Few minor suggestions and couple of questions:

  • I wonder if it would make sense to add tests for new code
  • We should probably capture changes in the USAGE.md.

Seems defaults are changing here as well, would it make sense to revert the logic to preserve current behaviour as default? 🤔

Comment thread cli/exec.go
BoolVar(&input.UseStdout)

cmd.Flag("device-code", "Use the device-code OAuth2 flow for SSO instead of the default PKCE browser flow").
BoolVar(&input.UseDeviceCode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add ENV handling as well?
OverrideDefaultFromEnvar("AWS_VAULT_DEVICE_CODE").

Comment thread cli/export.go
BoolVar(&input.UseStdout)

cmd.Flag("device-code", "Use the device-code OAuth2 flow for SSO instead of the default PKCE browser flow").
BoolVar(&input.UseDeviceCode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add ENV handling as well?
OverrideDefaultFromEnvar("AWS_VAULT_DEVICE_CODE").

Comment thread vault/ssorolecredentialsprovider.go
Comment on lines +248 to +249
go func() {
if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

select on ctx.Done() and tear down via defer:

Suggested change
go func() {
if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {
// ensure the callback server is torn down on any exit path
defer func() {
if err := cbServer.h.Close(); err != nil {
log.Printf("Failed to close oauthCallbackServer: %s", err)
}
}()
go func() {
if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("Failed to run oauthCallbackServer: %s", err)
}
}()
go func() {
if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {

Comment thread vault/ssorolecredentialsprovider.go Outdated
Comment on lines +284 to +288
r := <-cbServer.resultChan
// tear down the callback server
if err := cbServer.h.Close(); err != nil {
log.Printf("Failed to close oauthCallbackServer: %s", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r := <-cbServer.resultChan
// tear down the callback server
if err := cbServer.h.Close(); err != nil {
log.Printf("Failed to close oauthCallbackServer: %s", err)
}
// await the authorization code, or context cancellation
var r oauthCallbackResult
select {
case r = <-cbServer.resultChan:
case <-ctx.Done():
return nil, fmt.Errorf("aborted waiting for OAuth callback: %w", ctx.Err())
}
if r.err != nil {
return nil, r.err
}
if r.code == "" {
return nil, errors.New("no authorization code received")
}

Comment thread vault/ssorolecredentialsprovider.go
Comment thread vault/ssorolecredentialsprovider.go
Comment thread vault/ssorolecredentialsprovider.go Outdated
log.Printf("Failed to run oauthCallbackServer: %s", err)
}
}()
// keep a copy of the redirectURI for the CreateToken call (as the server will be closed after the code is received)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit on improving the comment here

Suggested change
// keep a copy of the redirectURI for the CreateToken call (as the server will be closed after the code is received)
// redirectURI is used in both the authorize URL and the later CreateToken call;
// bind it once so both sides agree on the same value.


// respond with a success message
io.WriteString(w, "Authorization code received, you can close this tab now.")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just ordering when to emit the message:

Suggested change
}
// respond with a success message before signalling completion, so the
// response is fully written before the main goroutine tears the server down
io.WriteString(w, "Authorization code received, you can close this tab now.")
s.resultChan <- oauthCallbackResult{code: code}

@mbevc1

mbevc1 commented May 18, 2026

Copy link
Copy Markdown
Contributor

Hi @patrickod, did you manage to look at the feedback?

@mbevc1

mbevc1 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@patrickod are you still working on this feature? I might need a rebase to trigger new Windows tests

@patrickod

Copy link
Copy Markdown
Author

@mbevc1 sorry I entered into a hectic period right as I cut this PR. I am happy to polish this up but as it might be next week until that happens.

For context this fork has been successfully deployed at Tailscale for a few months now, my hope was to encourage wider adoption of the PKCE flow as it is strictly more secure and closes a phishing vulnerability in the traditional flow. RE changing the default YMMV but I would argue this is an acceptable upgrade and is likely to be a net positive (one fewer click) for the majority of users. For those operating on remote systems where no browser is available to complete the flow the traditional flow remains a viable alternative.

Ported from tailscale/aws-vault@949040e

Inspired by AWS CLI this PR adds support for OAuth2 "authorization code"
grant flow accompanied by Proof Key for Code Exchange (PKCE) by OAuth
Public Clients and makes it the default.

In the "device code" flow the user is entirely responsible for matching
the one time password displayed by their terminal and browser. This is
error prone at best and a one click penalty for every login at worst.

In the "authorization code" flow we create an ephemeral HTTP server to
host an OAuth2 callback on 127.0.0.1. Users are redirected here after
confirming their login attempt and the code comparison step is automated
for them. As AWS enforces the use of 127.0.0.1 as a callback destination
host a remote attacker who successfully convinces a user to confirm
their malicious login prompt would not be able to access the resulting
credential as the user would be redirected to their own localhost and
not an attacker controlled destination.

If we determine that we are not able to open a browser (either from the
user specifying --stdout or observing that we are executing in an SSH
session) we revert to the previous "device code" flow and print the URLs
for copy/pasting into browsers.
@patrickod patrickod force-pushed the patrickod/pkce-byteness-7.10.5 branch from bf36047 to c86138a Compare June 10, 2026 01:03
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jun 10, 2026
@patrickod patrickod requested a review from mbevc1 June 10, 2026 01:20
// and sends the authorization code received via a channel.
func newOauthCallbackServer() (*oauthCallbackServer, error) {
// select a random port for the callback server
ln, err := net.Listen("tcp", ":0")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably scope this to localhost only:

Suggested change
ln, err := net.Listen("tcp", ":0")
ln, err := net.Listen("tcp", "127.0.0.1:0")

state := r.URL.Query().Get("state")
if subtle.ConstantTimeCompare([]byte(state), []byte(s.state)) != 1 {
http.Error(w, "Invalid state", http.StatusBadRequest)
s.resultChan <- oauthCallbackResult{err: errors.New("invalid state")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note and probably intentional?

A wrong/missing state (e.g., a port probe) pushes an error into resultChan, which the main goroutine treats as terminal. AWS CLI just ignores mismatched callbacks and keeps waiting.

@mbevc1

mbevc1 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Thanks @patrickod . It's a bit harder to review on a force-push, but I've left few comments, if you can have a look at those please?

Also I wonder and noticed the following:

  • login has no opt-out: cli/login.go never sets SSOUseDeviceCode and has no --device-code flag, so SSO profiles via aws-vault login are forced onto PKCE.
  • Missing error param handling - AWS CLI handles error/error_description in its callback handler, is this something we want to handle as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants