feat: Implement OAuth2 PKCE in SSORoleCredentialsProvider#359
Conversation
11c7540 to
bf36047
Compare
mbevc1
left a comment
There was a problem hiding this comment.
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? 🤔
| 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) |
There was a problem hiding this comment.
Could we add ENV handling as well?
OverrideDefaultFromEnvar("AWS_VAULT_DEVICE_CODE").
| 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) |
There was a problem hiding this comment.
Could we add ENV handling as well?
OverrideDefaultFromEnvar("AWS_VAULT_DEVICE_CODE").
| go func() { | ||
| if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) { |
There was a problem hiding this comment.
select on ctx.Done() and tear down via defer:
| 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) { |
| r := <-cbServer.resultChan | ||
| // tear down the callback server | ||
| if err := cbServer.h.Close(); err != nil { | ||
| log.Printf("Failed to close oauthCallbackServer: %s", err) | ||
| } |
There was a problem hiding this comment.
| 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") | |
| } |
| 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) |
There was a problem hiding this comment.
Minor nit on improving the comment here
| // 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.") | ||
| } |
There was a problem hiding this comment.
Just ordering when to emit the message:
| } | |
| // 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} |
|
Hi @patrickod, did you manage to look at the feedback? |
|
@patrickod are you still working on this feature? I might need a rebase to trigger new Windows tests |
|
@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.
bf36047 to
c86138a
Compare
| // 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") |
There was a problem hiding this comment.
We should probably scope this to localhost only:
| 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")} |
There was a problem hiding this comment.
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.
|
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:
|
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.